diff --git a/README.md b/README.md index 6efb544..ade614c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ It also contains these framework-specific rules (can be enabled separately): * Do not extend Nette\Object, use Nette\SmartObject trait instead * Rethrow exceptions that are always meant to be rethrown (like `AbortException`) +Links checking (can be enabled separately by - see configuration): +* Validate parameters passed to 'link()', 'lazyLink()', 'redirect()', 'redirectPermanent()', 'forward()', 'isLinkCurrent()' and 'canonicalize()' methods +* Works for presenters, components and 'LinkGenerator' service +* Checks if passed destination is valid and points to existing presenter, action or signal +* Checks if passed link parameters are valid and match relevant 'action*()', 'render*()' or 'handle*()' method signature +* Checks also links to sub-components of known types (createComponent*() method must exists) ## Installation @@ -52,3 +58,61 @@ To perform framework-specific checks, include also this file: ``` + +## Configuration + +### containerLoader + +Container loader can be used to create instance of Nette application DI container. + +Example: +```neon +parameters: + nette: + containerLoader: './containerLoader.php' +``` + +Example `containerLoader.php`: + +```php +createContainer(); +``` + +### applicationMapping + +Application mapping is used to map presenter identfiers to classes in link checking. + +Example: +```neon +parameters: + nette: + applicationMapping: + *: App\Presenters\*\*Presenter +``` + +### checkLinks + +Link checking can be disabled/enabled by setting `checkLinks` parameter. It is enabled by default if `bleedingEndge` is enabled. + +Either `applicationMapping` or `containerLoader` (for automatically loading mappings from `PresenterFactory` service in your app) must be set for link checking to work. + +Example: +```neon +parameters: + nette: + checkLinks: true +``` + +If you use non-standard `PresenterFactory` this feature might not work because logic for mapping presenter name (e.g. `MyModule:Homepage`) to presenter class (e.g. `\App\Presenters\MyModule\HomepagePresenter`) and vice versa would work differently. + +If you use `containerLoader` you might solve this by implementing method `unformatPresenterClass` in your custom `PresenterFactory` class. This method should return presenter name for given presenter class. + +Or you can create custom implementation overriding `PHPStan\Nette\PresenterResolver` service and replace it in your PHPStan config: + +```neon +services: + nettePresenterResolver: + class: MyCustom\PresenterResolver +``` diff --git a/composer.json b/composer.json index 78a1277..3437225 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ }, "require-dev": { "nette/application": "^3.0", + "nette/di": "^2.3.0 || ^3.0.0", "nette/forms": "^3.0", "nette/utils": "^2.3.0 || ^3.0.0", "nikic/php-parser": "^4.13.2", diff --git a/extension.neon b/extension.neon index c8c147f..aa3dec0 100644 --- a/extension.neon +++ b/extension.neon @@ -1,4 +1,8 @@ parameters: + nette: + containerLoader: null + applicationMapping: [] + checkLinks: %featureToggles.bleedingEdge% additionalConstructors: - Nette\Application\UI\Presenter::startup exceptions: @@ -48,7 +52,30 @@ parameters: - terminate - forward +parametersSchema: + nette: structure([ + containerLoader: schema(string(), nullable()) + applicationMapping: arrayOf(anyOf(string(), listOf(string()))) + checkLinks: bool() + ]) + services: + netteContainerResolver: + class: PHPStan\Nette\ContainerResolver + arguments: + - %nette.containerLoader% + + nettePresenterResolver: + class: PHPStan\Nette\PresenterResolver + arguments: + - %nette.applicationMapping% + + - + class: PHPStan\Nette\LinkChecker + + - + class: PHPStan\Reflection\Nette\HtmlClassReflectionExtension + - class: PHPStan\Reflection\Nette\HtmlClassReflectionExtension tags: diff --git a/rules.neon b/rules.neon index a34648d..fb4ff73 100644 --- a/rules.neon +++ b/rules.neon @@ -20,6 +20,12 @@ rules: conditionalTags: PHPStan\Rule\Nette\RegularExpressionPatternRule: phpstan.rules.rule: %featureToggles.bleedingEdge% + PHPStan\Rule\Nette\ComponentLinksRule: + phpstan.rules.rule: %nette.checkLinks% + PHPStan\Rule\Nette\PresenterLinksRule: + phpstan.rules.rule: %nette.checkLinks% + PHPStan\Rule\Nette\LinkGeneratorLinksRule: + phpstan.rules.rule: %nette.checkLinks% services: - @@ -30,3 +36,9 @@ services: - phpstan.rules.rule - class: PHPStan\Rule\Nette\RegularExpressionPatternRule + - + class: PHPStan\Rule\Nette\ComponentLinksRule + - + class: PHPStan\Rule\Nette\PresenterLinksRule + - + class: PHPStan\Rule\Nette\LinkGeneratorLinksRule diff --git a/src/Exceptions/InvalidLinkDestinationException.php b/src/Exceptions/InvalidLinkDestinationException.php new file mode 100644 index 0000000..9877d5a --- /dev/null +++ b/src/Exceptions/InvalidLinkDestinationException.php @@ -0,0 +1,25 @@ +destination = $destination; + } + + public function getDestination(): string + { + return $this->destination; + } + +} diff --git a/src/Exceptions/InvalidLinkException.php b/src/Exceptions/InvalidLinkException.php new file mode 100644 index 0000000..e027649 --- /dev/null +++ b/src/Exceptions/InvalidLinkException.php @@ -0,0 +1,10 @@ +containerLoader = $containerLoader; + } + + public function getContainer(): ?Container + { + if ($this->container === false) { + return null; + } + + if ($this->container !== null) { + return $this->container; + } + + if ($this->containerLoader === null) { + $this->container = false; + + return null; + } + + $this->container = $this->loadContainer($this->containerLoader); + + return $this->container; + } + + + private function loadContainer(string $containerLoader): ?Container + { + if (!is_file($containerLoader)) { + throw new ShouldNotHappenException(sprintf( + 'Nette container could not be loaded: file "%s" does not exist', + $containerLoader + )); + } + + if (!is_readable($containerLoader)) { + throw new ShouldNotHappenException(sprintf( + 'Nette container could not be loaded: file "%s" is not readable', + $containerLoader + )); + } + + return require $containerLoader; + } + +} diff --git a/src/Nette/LinkChecker.php b/src/Nette/LinkChecker.php new file mode 100644 index 0000000..7180947 --- /dev/null +++ b/src/Nette/LinkChecker.php @@ -0,0 +1,395 @@ +presenterResolver = $presenterResolver; + $this->reflectionProvider = $reflectionProvider; + } + + /** + * @param array $currentClasses + * @param array $destinations + * @param array|null> $paramsOptions + * @return array + */ + public function checkLinkVariants(Scope $scope, array $currentClasses, string $methodName, array $destinations, array $paramsOptions = [null]): array + { + if ($paramsOptions === []) { + $paramsOptions = [null]; + } + + $errors = []; + foreach ($destinations as $destination) { + foreach ($paramsOptions as $params) { + foreach ($currentClasses as $currentClass) { + $errors = array_merge($errors, $this->checkLinkError($scope, $currentClass, $methodName, $destination, $params)); + } + } + } + return array_values($errors); + } + + /** + * @param array|null $params + * @return array + */ + public function checkLink(Scope $scope, ?string $currentClass, string $methodName, string $destination, ?array $params = null): array + { + return array_values($this->checkLinkError($scope, $currentClass, $methodName, $destination, $params)); + } + + /** + * @param array|null $params + * @return array + */ + public function checkLinkError(Scope $scope, ?string $currentClass, string $methodName, string $destination, ?array $params = null): array + { + try { + $this->validateLink($scope, $currentClass, $destination, $params); + return []; + } catch (PresenterResolvingNotAvailableException $e) { + return []; // link checking not enabled + } catch (InvalidLinkDestinationException $e) { + $message = sprintf("Invalid link destination '%s' in %s() call", $e->getDestination(), $methodName) . + ($e->getPrevious() !== null ? ': ' . $e->getPrevious()->getMessage() : '.'); + $identifier = 'nette.invalidLink.destination'; + } catch (InvalidLinkParamsException $e) { + $message = sprintf('Invalid link params in %s() call: ', $methodName) . $e->getMessage(); + $identifier = 'nette.invalidLink.params'; + } catch (InvalidLinkException $e) { + $message = sprintf('Invalid link in %s() call: ', $methodName) . $e->getMessage(); + $identifier = 'nette.invalidLink.error'; + } catch (LinkCheckFailedException $e) { + $message = sprintf('Link check failed: ' . $e->getMessage()); + $identifier = 'nette.invalidLink.checkFailed'; + } catch (PresenterResolvingException $e) { + $message = sprintf('Link check failed: ' . $e->getMessage()); + $identifier = 'nette.invalidLink.presenterResolvingFailed'; + } catch (Throwable $e) { + $message = sprintf('Link check failed: %s: %s in %s on line %s', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()); + $identifier = 'nette.invalidLink.unexpectedError'; + } + return [$message => RuleErrorBuilder::message($message)->identifier($identifier)->build()]; + } + + /** + * @param array|null $params + */ + private function validateLink(Scope $scope, ?string $currentClass, string $destination, ?array $params): void + { + if ($currentClass !== null) { + $reflection = $this->reflectionProvider->getClass($currentClass); + $isComponent = $reflection->is(Component::class); + $isPresenter = $reflection->is(Presenter::class); + $isLinkGenerator = false; + } else { + $isComponent = false; + $isPresenter = false; + $isLinkGenerator = true; + } + + if ($isLinkGenerator && preg_match('~^([\w:]+):(\w*+)(#.*)?()$~D', $destination) !== 1) { + throw new InvalidLinkDestinationException($destination); + } + + if (preg_match('~^ (?//)?+ (?[^!?#]++) (?!)?+ (?\?[^#]*)?+ (?\#.*)?+ $~x', $destination, $matches) !== 1) { + throw new InvalidLinkDestinationException($destination); + } + + $path = $matches['path'] ?? ''; + $signal = $matches['signal'] ?? ''; + $query = $matches['query'] ?? ''; + + if ($query !== '') { + parse_str(substr($query, 1), $queryParams); + $queryParamsTypes = []; + foreach ($queryParams as $key => $value) { + if (is_array($value)) { + $queryParamsTypes[$key] = new ArrayType(new MixedType(), new MixedType()); + } else { + $queryParamsTypes[$key] = new ConstantStringType($value); + } + } + $params = $params !== null ? array_merge($queryParamsTypes, $params) : null; + } + + if (($isComponent && !$isPresenter) || $signal !== '') { + $pathSepPos = strrpos($path, ':'); + if ($pathSepPos !== false) { + $signal = substr($path, $pathSepPos + 1); + } else { + $signal = $path; + } + if ($signal === '' || $signal === false) { + throw new InvalidLinkDestinationException($destination, 0, new InvalidLinkException('Signal must be non-empty string.')); + } + $path = 'this'; + } + + $pathSepPos = strrpos($path, ':'); + if ($pathSepPos !== false && $pathSepPos !== 0) { + $presenter = substr($path, 0, $pathSepPos); + $action = substr($path, $pathSepPos + 1); + } else { + $presenter = 'this'; + $action = $path; + } + + if ($isLinkGenerator) { + if ($presenter[0] === ':') { + throw new InvalidLinkDestinationException($destination, 0, new InvalidLinkException('Do not use absolute destinations with LinkGenerator.')); + } + $presenter = ':' . $presenter; + } + + if ($currentClass !== null) { + $currentClassReflection = $this->reflectionProvider->getClass($currentClass); + + if ($currentClassReflection->isAbstract() && $presenter !== 'this' && $presenter[0] !== ':') { + throw new LinkCheckFailedException(sprintf('Cannot analyse relative destination \'%s\' for abstract class %s.', $destination, $currentClass)); + } + } + + if ($action !== '' && $action !== '*' && preg_match('/^[a-zA-Z0-9_]+$/', $action) !== 1) { + throw new InvalidLinkDestinationException($destination); + } + + if ($signal !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $signal) !== 1) { + throw new InvalidLinkDestinationException($destination); + } + + if ($action === '') { + $action = 'default'; + } + + if ($isComponent && !$isPresenter) { + $targetClass = $currentClass; + } else { + try { + $targetClass = $this->presenterResolver->getPresenterClassByName($presenter, $isPresenter ? $currentClass : null); + } catch (PresenterResolvingException $e) { + throw new InvalidLinkDestinationException($destination, 0, $e); + } + } + + if ($targetClass === null) { + return; + } + + if ($signal !== '') { + try { + $this->validateSignal($scope, $targetClass, $signal, $params); + } catch (InvalidLinkDestinationException $e) { + throw new InvalidLinkDestinationException($destination, 0, $e->getPrevious()); + } + } + + $targetClassReflection = $this->reflectionProvider->getClass($targetClass); + + if ($params === null || $action === '*' || $action === 'this' || !$targetClassReflection->isSubclassOf(Presenter::class)) { + return; + } + + $hasCustomFormatActionMethod = $targetClassReflection->hasMethod('formatActionMethod') && + $targetClassReflection->getMethod('formatActionMethod', $scope)->getDeclaringClass()->getName() !== Presenter::class; + $actionMethod = $hasCustomFormatActionMethod && is_callable([$targetClass, 'formatActionMethod']) ? $targetClass::formatActionMethod($action) : 'action' . ucfirst($action); + + $hasCustomFormatRenderMethod = $targetClassReflection->hasMethod('formatRenderMethod') && + $targetClassReflection->getMethod('formatActionMethod', $scope)->getDeclaringClass()->getName() !== Presenter::class; + $renderMethod = $hasCustomFormatRenderMethod && is_callable([$targetClass, 'formatRenderMethod']) ? $targetClass::formatRenderMethod($action) : 'render' . ucfirst($action); + + if ($targetClassReflection->hasMethod($actionMethod)) { + $this->validateParams($scope, $targetClass, $actionMethod, $params); + //detect deprecated? + } elseif ($targetClassReflection->hasMethod($renderMethod)) { + $this->validateParams($scope, $targetClass, $renderMethod, $params); + //detect deprecated? + } elseif (array_key_exists(0, $params)) { + throw new InvalidLinkParamsException(sprintf("Unable to pass parameters to action '%s:%s', missing corresponding method in %s.", $presenter, $action, $targetClass)); + } else { + $targetClassFileName = $targetClassReflection->getFileName(); + if ($targetClassFileName === null) { + return; + } + $targetClassDir = dirname($targetClassFileName); + $templateDir = is_dir($targetClassDir . '/templates') ? $targetClassDir : dirname($targetClassDir); + $templates = [ + sprintf('%s/templates/%s/%s.latte', $templateDir, ltrim($presenter, ':'), $action), + sprintf('%s/templates/%s.%s.latte', $templateDir, ltrim($presenter, ':'), $action), + ]; + foreach ($templates as $template) { + if (is_file($template)) { + return; + } + } + throw new InvalidLinkDestinationException( + $destination, + 0, + new InvalidLinkException(sprintf("Action '%s:%s' does not exists, missing corresponding template or method in %s.", $presenter, $action, $targetClass)) + ); + } + } + + /** + * @param array $params + */ + private function validateParams( + Scope $scope, + string $class, + string $method, + array &$params + ): void + { + $i = 0; + $rm = $this->reflectionProvider->getClass($class)->getMethod($method, $scope); + $declaringClass = $rm->getDeclaringClass()->getName(); + + $selectedVariant = ParametersAcceptorSelector::selectFromTypes($params, $rm->getVariants(), false); + + foreach ($selectedVariant->getParameters() as $param) { + $expectedType = $param->getType(); + $name = $param->getName(); + + if (array_key_exists($i, $params)) { + $params[$name] = $params[$i]; + unset($params[$i]); + $i++; + } + + if (!isset($params[$name])) { + if ( + $param->getDefaultValue() === null + && $expectedType->isNull()->no() + && $expectedType->isScalar()->no() + && $expectedType->isArray()->no() + && $expectedType->isIterable()->no() + ) { + throw new InvalidLinkParamsException(sprintf('Missing parameter $%s required by %s::%s()', $param->getName(), $declaringClass, $method)); + } + continue; + } + + $actualType = $params[$name]; + if ($expectedType->accepts($actualType, false)->no()) { + throw new InvalidLinkParamsException(sprintf( + 'Argument $%s passed to %s() must be %s, %s given.', + $name, + $declaringClass . '::' . $method, + $expectedType->describe(VerbosityLevel::precise()), + $actualType->describe(VerbosityLevel::precise()) + )); + } + } + + if (array_key_exists($i, $params)) { + throw new InvalidLinkParamsException(sprintf('Passed more parameters than method %s::%s() expects.', $declaringClass, $method)); + } + } + + /** + * @param array $params + */ + private function validateSignal( + Scope $scope, + string $targetClass, + string $signal, + ?array $params + ): void + { + $targetClassReflection = $this->reflectionProvider->getClass($targetClass); + if ($signal === 'this') { // means "no signal" + if ($params !== null && array_key_exists(0, $params)) { + throw new InvalidLinkParamsException("Unable to pass parameters to 'this!' signal."); + } + } elseif (strpos($signal, '-') === false) { + $hasCustomFormatActionMethod = $targetClassReflection->hasMethod('formatSignalMethod') && + $targetClassReflection->getMethod('formatSignalMethod', $scope)->getDeclaringClass()->getName() !== Component::class; + $signalMethod = $hasCustomFormatActionMethod && is_callable([$targetClass, 'formatSignalMethod']) ? $targetClass::formatSignalMethod($signal) : 'handle' . ucfirst($signal); + if (!$targetClassReflection->hasMethod($signalMethod)) { + throw new InvalidLinkDestinationException( + $signal, + 0, + new InvalidLinkException(sprintf("Unknown signal '%s', missing handler %s::%s()", $signal, $targetClass, $signalMethod)) + ); + } + $this->validateParams($scope, $targetClass, $signalMethod, $params); + //detect deprecated? + } else { + [$componentName, $componentSignal] = explode('-', $signal, 2); + $subComponentMethodName = 'createComponent' . ucfirst($componentName); + if (!$targetClassReflection->hasMethod($subComponentMethodName)) { + throw new InvalidLinkDestinationException( + $signal, + 0, + new InvalidLinkException(sprintf( + "Sub-component '%s' might not exists. Method %s::%s() not found.", + $componentName, + $targetClass, + $subComponentMethodName + )) + ); + } + + $subComponentMethod = $targetClassReflection->getMethod($subComponentMethodName, $scope); + $subComponentType = ParametersAcceptorSelector::selectSingle($subComponentMethod->getVariants())->getReturnType(); + foreach ($subComponentType->getReferencedClasses() as $componentClass) { + $subComponentClassReflection = $this->reflectionProvider->getClass($componentClass); + if (!$subComponentClassReflection->isSubclassOf(Component::class)) { + continue; + } + + $this->validateLink($scope, $componentClass, $componentSignal, $params); + } + } + } + +} diff --git a/src/Nette/PresenterResolver.php b/src/Nette/PresenterResolver.php new file mode 100644 index 0000000..ae67ca9 --- /dev/null +++ b/src/Nette/PresenterResolver.php @@ -0,0 +1,216 @@ + */ + protected $mapping; + + /** @var ContainerResolver */ + private $containerResolver; + + /** @var ?ReflectionProvider */ + private $reflectionProvider; + + /** @var IPresenterFactory */ + private $presenterFactory; + + /** + * @param array $mapping + */ + public function __construct(array $mapping, ContainerResolver $containerResolver, ?ReflectionProvider $reflectionProvider) + { + $this->mapping = $mapping; + $this->containerResolver = $containerResolver; + $this->reflectionProvider = $reflectionProvider; + } + + protected function getPresenterFactory(): IPresenterFactory + { + if ($this->presenterFactory === null) { + if ($this->mapping !== []) { + $this->presenterFactory = new PresenterFactory(); + $this->presenterFactory->setMapping($this->mapping); + } elseif ($this->containerResolver->getContainer() !== null) { + $this->presenterFactory = $this->containerResolver->getContainer()->getByType(IPresenterFactory::class); + } else { + throw new PresenterResolvingNotAvailableException( + 'Cannot resolve presenter, no mapping is defined.' . + ' Please provide explicit mappings in parameters.nette.applicationMapping or use parameters.nette.containerLoader to load it automatically.' + ); + } + } + return $this->presenterFactory; + } + + /** + * @return array + * @throws ShouldNotHappenException + * @throws ReflectionException + */ + protected function getCurrentMapping(): array + { + if ($this->mapping !== []) { + $convertedMapping = []; + foreach ($this->mapping as $module => $mask) { + if (is_string($mask)) { + if (preg_match('#^\\\\?([\w\\\\]*\\\\)?(\w*\*\w*?\\\\)?([\w\\\\]*\*\w*)$#D', $mask, $m) !== 1) { + throw new ShouldNotHappenException(sprintf("Invalid mapping mask '%s' in parameters.nette.applicationMapping.", $mask)); + } + $convertedMapping[$module] = [$m[1], $m[2] !== '' ? $m[2] : '*Module\\', $m[3]]; + } elseif (is_array($mask) && count($mask) === 3) { /** @phpstan-ignore-line */ + $convertedMapping[$module] = [$mask[0] !== '' ? $mask[0] . '\\' : '', $mask[1] . '\\', $mask[2]]; + } else { + throw new PresenterResolvingException(sprintf('Invalid mapping mask for module %s in parameters.nette.applicationMapping.', $module)); + } + } + return $convertedMapping; + } + + return $this->extractMappingFromPresenterFactory($this->getPresenterFactory()); + } + + public function getPresenterClassByName(string $name, ?string $currentPresenterClass = null): string + { + if ($name === 'this' && $currentPresenterClass !== null) { + return $currentPresenterClass; + } + + try { + $name = $this->resolvePresenterName($name, $currentPresenterClass); + $presenterClass = $this->getPresenterClass($name); + } catch (InvalidPresenterException $e) { + throw new PresenterResolvingException($e->getMessage(), $e->getCode(), $e); + } + + if ($this->reflectionProvider !== null) { + if (!$this->reflectionProvider->hasClass($presenterClass)) { + throw new PresenterResolvingException(sprintf("Cannot load presenter '%s', class '%s' was not found.", $name, $presenterClass)); + } + + $presenterClassReflection = $this->reflectionProvider->getClass($presenterClass); + + if (!$presenterClassReflection->implementsInterface(IPresenter::class)) { + throw new PresenterResolvingException(sprintf("Cannot load presenter '%s', class '%s' is not Nette\\Application\\IPresenter implementor.", $name, $presenterClass)); + } + } + + return $presenterClass; + } + + public function resolvePresenterName(string $name, ?string $currentPresenterClass = null): string + { + if ($name[0] === ':') { + return substr($name, 1); + } elseif ($currentPresenterClass === null) { + throw new PresenterResolvingException(sprintf("Cannot resolve relative presenter name '%s' - current presenter is not set.", $name)); + } + + $currentName = $this->unformatPresenterClass($currentPresenterClass); + $currentNameSepPos = strrpos($currentName, ':'); + if ($currentNameSepPos !== false && $currentNameSepPos !== 0) { + $currentModule = substr($currentName, 0, $currentNameSepPos); + $currentPresenter = substr($currentName, $currentNameSepPos + 1); + } else { + $currentModule = ''; + $currentPresenter = $currentName; + } + + if ($name === 'this') { + return $currentModule . ':' . $currentPresenter; + } + + return $currentModule . ':' . $name; + } + + /** + * @return array + * Override just this method if you use non-standard PresenterFactory but with standard mapping logic to extract mapping from it. + */ + protected function extractMappingFromPresenterFactory(object $presenterFactory) + { + if (!$presenterFactory instanceof PresenterFactory) { + throw new PresenterResolvingException( + 'PresenterFactory in your container is not instance of Nette\Application\PresenterFactory. We cannot get mapping from it.' . + ' Either set your mappings explicitly in parameters.nette.applicationMapping ' . + ' or replace service nettePresenterResolver with your own override of getPresenterClass() and/or unformatPresenterClass().' + ); + } + + $presenterFactoryMappingProperty = 'mapping'; + + $mappingPropertyReflection = (new ReflectionClass($presenterFactory))->getProperty($presenterFactoryMappingProperty); + $mappingPropertyReflection->setAccessible(true); + /** @var array $mapping */ + $mapping = $mappingPropertyReflection->getValue($presenterFactory); + + return $mapping; + } + + /** + * Convert presenter name to presenter class name (for example MyModule:Homepage to \App\Presenters\MyModule\HomepagePresenter) + * Override this method if you use non-standard PresenterFactory with custom mapping logic. + */ + protected function getPresenterClass(string $name): string + { + if (is_callable([$this->getPresenterFactory(), 'formatPresenterClass'])) { + return $this->getPresenterFactory()->formatPresenterClass($name); + } + + return $this->getPresenterFactory()->getPresenterClass($name); + } + + /** + * Convert presenter class name to presenter name (for example \App\Presenters\MyModule\HomepagePresenter to MyModule:Homepage + * Override this method if you use non-standard PresenterFactory with custom mapping logic. + */ + protected function unformatPresenterClass(string $class): string + { + $presenterName = null; + + if (is_callable([$this->getPresenterFactory(), 'unformatPresenterClass'])) { + // silenced because it can throw deprecated error in some versions of Nette + $presenterName = @$this->getPresenterFactory()->unformatPresenterClass($class); + } else { + foreach ($this->getCurrentMapping() as $module => $mapping) { + $mapping = str_replace(['\\', '*'], ['\\\\', '(\w+)'], $mapping); + if (preg_match('#^\\\\?' . $mapping[0] . '((?:' . $mapping[1] . ')*)' . $mapping[2] . '$#Di', $class, $matches) !== 1) { + continue; + } + + $presenterName = ($module === '*' ? '' : $module . ':') + . preg_replace('#' . $mapping[1] . '#iA', '$1:', $matches[1]) . $matches[3]; + } + } + + if ($presenterName === null) { + throw new PresenterResolvingException(sprintf("Cannot convert presenter class '%s' to presenter name. No matching mapping found.", $class)); + } + + return $presenterName; + } + +} diff --git a/src/Rule/Nette/ComponentLinksRule.php b/src/Rule/Nette/ComponentLinksRule.php new file mode 100644 index 0000000..934e2a4 --- /dev/null +++ b/src/Rule/Nette/ComponentLinksRule.php @@ -0,0 +1,64 @@ + + */ +class ComponentLinksRule extends LinksRule +{ + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->toString(); + $callerType = $scope->getType($node->var); + $args = $node->getArgs(); + + if (!isset($args[0])) { + return []; + } + + if ((new ObjectType(Component::class))->isSuperTypeOf($callerType)->no()) { + return []; + } + + if ((new ObjectType(Presenter::class))->isSuperTypeOf($callerType)->yes()) { + return []; + } + + if (!in_array($methodName, ['link', 'lazyLink', 'isLinkCurrent', 'redirect', 'redirectPermanent'], true)) { + return []; + } + + $destinationArg = $args[0]; + $paramArgs = array_slice($args, 1); + + $destinations = $this->extractDestintionsFromArg($scope, $destinationArg); + if (count($paramArgs) === 1 && $scope->getType($paramArgs[0]->value)->isArray()->yes()) { + $paramsVariants = $this->extractParamVariantsFromArrayArg($scope, $paramArgs[0] ?? null); + } else { + $paramsVariants = $this->extractParamVariantsFromArgs($scope, $paramArgs); + } + return $this->linkChecker->checkLinkVariants($scope, $callerType->getObjectClassNames(), $methodName, $destinations, $paramsVariants); + } + +} diff --git a/src/Rule/Nette/LinkGeneratorLinksRule.php b/src/Rule/Nette/LinkGeneratorLinksRule.php new file mode 100644 index 0000000..7bf92e5 --- /dev/null +++ b/src/Rule/Nette/LinkGeneratorLinksRule.php @@ -0,0 +1,53 @@ + + */ +class LinkGeneratorLinksRule extends LinksRule +{ + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->toString(); + $callerType = $scope->getType($node->var); + $args = $node->getArgs(); + + if (!isset($args[0])) { + return []; + } + + if ((new ObjectType(LinkGenerator::class))->isSuperTypeOf($callerType)->no()) { + return []; + } + + if ($methodName !== 'link') { + return []; + } + + $destinationArg = $args[0]; + $paramArgs = array_slice($args, 1); + + $destinations = $this->extractDestintionsFromArg($scope, $destinationArg); + $paramsVariants = $this->extractParamVariantsFromArrayArg($scope, $paramArgs[0] ?? null); + return $this->linkChecker->checkLinkVariants($scope, [null], $methodName, $destinations, $paramsVariants); + } + +} diff --git a/src/Rule/Nette/LinksRule.php b/src/Rule/Nette/LinksRule.php new file mode 100644 index 0000000..da5372c --- /dev/null +++ b/src/Rule/Nette/LinksRule.php @@ -0,0 +1,82 @@ + + */ +abstract class LinksRule implements Rule +{ + + /** @var LinkChecker */ + protected $linkChecker; + + public function __construct(LinkChecker $linkChecker) + { + $this->linkChecker = $linkChecker; + } + + /** + * @return array + */ + protected function extractDestintionsFromArg(Scope $scope, ?Arg $arg): array + { + if ($arg === null) { + return []; + } + $type = $scope->getType($arg->value); + $destinations = []; + foreach ($type->getConstantStrings() as $constantString) { + $destinations[] = $constantString->getValue(); + } + return $destinations; + } + + /** + * @return array> + */ + protected function extractParamVariantsFromArrayArg(Scope $scope, ?Arg $arg): array + { + if ($arg === null) { + return [[]]; + } + $type = $scope->getType($arg->value); + $paramsVariants = []; + foreach ($type->getConstantArrays() as $array) { + $params = []; + $keyTypes = $array->getKeyTypes(); + $valueTypes = $array->getValueTypes(); + foreach ($keyTypes as $index => $keyType) { + if ($keyType->isConstantValue()->no()) { + break; + } + $params[$keyType->getValue()] = $valueTypes[$index]; + } + $paramsVariants[] = $params; + } + + return $paramsVariants; + } + + /** + * @param array $args + * @return array> + */ + protected function extractParamVariantsFromArgs(Scope $scope, array $args): array + { + $params = []; + foreach ($args as $arg) { + $params[] = $scope->getType($arg->value); + } + return [$params]; + } + +} diff --git a/src/Rule/Nette/PresenterLinksRule.php b/src/Rule/Nette/PresenterLinksRule.php new file mode 100644 index 0000000..3d8db0e --- /dev/null +++ b/src/Rule/Nette/PresenterLinksRule.php @@ -0,0 +1,59 @@ + + */ +class PresenterLinksRule extends LinksRule +{ + + public function getNodeType(): string + { + return MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $methodName = $node->name->toString(); + $callerType = $scope->getType($node->var); + $args = $node->getArgs(); + + if (!isset($args[0])) { + return []; + } + + if ((new ObjectType(Presenter::class))->isSuperTypeOf($callerType)->no()) { + return []; + } + + if (!in_array($methodName, ['link', 'lazyLink', 'isLinkCurrent', 'redirect', 'redirectPermanent', 'canonicalize', 'forward'], true)) { + return []; + } + + $destinationArg = $args[0]; + $paramArgs = array_slice($args, 1); + + $destinations = $this->extractDestintionsFromArg($scope, $destinationArg); + if (count($paramArgs) === 1 && $scope->getType($paramArgs[0]->value)->isArray()->yes()) { + $paramsVariants = $this->extractParamVariantsFromArrayArg($scope, $paramArgs[0] ?? null); + } else { + $paramsVariants = $this->extractParamVariantsFromArgs($scope, $paramArgs); + } + return $this->linkChecker->checkLinkVariants($scope, $callerType->getObjectClassNames(), $methodName, $destinations, $paramsVariants); + } + +} diff --git a/tests/Nette/PresenterResolverTest.php b/tests/Nette/PresenterResolverTest.php new file mode 100644 index 0000000..6e0771e --- /dev/null +++ b/tests/Nette/PresenterResolverTest.php @@ -0,0 +1,45 @@ +presenterResolver = new PresenterResolver( + [], + new ContainerResolver(__DIR__ . '/containerLoader.php'), + null + ); + } + + public function testResolvePresenterName(): void + { + self::assertSame('Test', $this->presenterResolver->resolvePresenterName(':Test')); + self::assertSame('TestModule:Test', $this->presenterResolver->resolvePresenterName(':TestModule:Test')); + self::assertSame('TestModule:SubModule:Test', $this->presenterResolver->resolvePresenterName(':TestModule:SubModule:Test')); + + $currentPresenterClass = 'PHPStan\TestApp\Presenters\CurrentModule\CurrentPresenter'; + self::assertSame('CurrentModule:Current', $this->presenterResolver->resolvePresenterName('this', $currentPresenterClass)); + self::assertSame('CurrentModule:Test', $this->presenterResolver->resolvePresenterName('Test', $currentPresenterClass)); + self::assertSame('CurrentModule:SubModule:Test', $this->presenterResolver->resolvePresenterName('SubModule:Test', $currentPresenterClass)); + } + + public function testGetPresenterClassByName(): void + { + self::assertSame('PHPStan\TestApp\Presenters\TestPresenter', $this->presenterResolver->getPresenterClassByName(':Test')); + self::assertSame('PHPStan\TestApp\Presenters\TestModule\TestPresenter', $this->presenterResolver->getPresenterClassByName(':TestModule:Test')); + + $currentPresenterClass = 'PHPStan\TestApp\Presenters\CurrentModule\CurrentPresenter'; + self::assertSame('PHPStan\TestApp\Presenters\CurrentModule\CurrentPresenter', $this->presenterResolver->getPresenterClassByName('this', $currentPresenterClass)); + self::assertSame('PHPStan\TestApp\Presenters\CurrentModule\TestPresenter', $this->presenterResolver->getPresenterClassByName('Test', $currentPresenterClass)); + self::assertSame('PHPStan\TestApp\Presenters\CurrentModule\SubModule\TestPresenter', $this->presenterResolver->getPresenterClassByName('SubModule:Test', $currentPresenterClass)); + } + +} diff --git a/tests/Nette/containerLoader.php b/tests/Nette/containerLoader.php new file mode 100644 index 0000000..f0243b4 --- /dev/null +++ b/tests/Nette/containerLoader.php @@ -0,0 +1,8 @@ + + */ +class ComponentLinksRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ComponentLinksRule( + new LinkChecker( + new PresenterResolver( + ['*' => 'PHPStan\TestApp\Presenters\*\*Presenter'], + new ContainerResolver(null), + self::getContainer()->getByType(ReflectionProvider::class) + ), + self::getContainer()->getByType(ReflectionProvider::class) + ) + ); + } + + public function testRule(): void + { + require_once __DIR__ . '/../../TestApp/autoload.php'; + $this->analyse([__DIR__ . '/data/links-component.php'], [ + [ + 'Invalid link destination \'***\' in link() call.', + 5, + ], + [ + 'Invalid link destination \'Test\' in link() call: Unknown signal \'Test\', missing handler PHPStan\TestApp\Components\CurrentComponent::handleTest()', + 7, + ], + [ + 'Invalid link destination \':Test\' in link() call: Unknown signal \'Test\', missing handler PHPStan\TestApp\Components\CurrentComponent::handleTest()', + 8, + ], + [ + 'Invalid link destination \'unknown\' in link() call: Unknown signal \'unknown\', missing handler PHPStan\TestApp\Components\CurrentComponent::handleUnknown()', + 13, + ], + [ + 'Invalid link destination \'subComponent-unknown\' in link() call: Unknown signal \'unknown\', missing handler PHPStan\TestApp\Components\CurrentComponent::handleUnknown()', + 14, + ], + [ + 'Invalid link destination \'unknownComponent-signal\' in link() call: Sub-component \'unknownComponent\' might not exists. Method PHPStan\TestApp\Components\CurrentComponent::createComponentUnknownComponent() not found.', + 16, + ], + [ + 'Invalid link destination \'unknown!\' in link() call: Unknown signal \'unknown\', missing handler PHPStan\TestApp\Components\CurrentComponent::handleUnknown()', + 21, + ], + [ + 'Invalid link destination \'subComponent-unknown!\' in link() call: Unknown signal \'unknown\', missing handler PHPStan\TestApp\Components\CurrentComponent::handleUnknown()', + 22, + ], + [ + 'Invalid link destination \'unknownComponent-signal!\' in link() call: Sub-component \'unknownComponent\' might not exists. Method PHPStan\TestApp\Components\CurrentComponent::createComponentUnknownComponent() not found.', + 24, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Components\CurrentComponent::handleWithParam() must be int, null given.', + 27, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Components\CurrentComponent::handleWithParam() must be int, null given.', + 28, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Components\CurrentComponent::handleWithParam() must be int, null given.', + 29, + ], + [ + 'Invalid link destination \'***\' in lazyLink() call.', + 31, + ], + [ + 'Invalid link destination \'***\' in isLinkCurrent() call.', + 32, + ], + [ + 'Invalid link destination \'***\' in redirect() call.', + 33, + ], + [ + 'Invalid link destination \'***\' in redirectPermanent() call.', + 34, + ], + ]); + } + +} diff --git a/tests/Rule/Nette/LinkGeneratorLinksRuleTest.php b/tests/Rule/Nette/LinkGeneratorLinksRuleTest.php new file mode 100644 index 0000000..51088da --- /dev/null +++ b/tests/Rule/Nette/LinkGeneratorLinksRuleTest.php @@ -0,0 +1,83 @@ + + */ +class LinkGeneratorLinksRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new LinkGeneratorLinksRule( + new LinkChecker( + new PresenterResolver( + ['*' => 'PHPStan\TestApp\Presenters\*\*Presenter'], + new ContainerResolver(null), + self::getContainer()->getByType(ReflectionProvider::class) + ), + self::getContainer()->getByType(ReflectionProvider::class) + ) + ); + } + + public function testRule(): void + { + require_once __DIR__ . '/../../TestApp/autoload.php'; + $this->analyse([__DIR__ . '/data/links-linkGenerator.php'], [ + [ + 'Invalid link destination \'***\' in link() call.', + 5, + ], + [ + 'Invalid link destination \'this\' in link() call.', + 7, + ], + [ + 'Invalid link destination \'Test\' in link() call.', + 8, + ], + [ + 'Invalid link destination \':Test:default\' in link() call: Do not use absolute destinations with LinkGenerator.', + 12, + ], + [ + 'Invalid link destination \'Unknown:default\' in link() call: Cannot load presenter \'Unknown\', class \'PHPStan\TestApp\Presenters\UnknownPresenter\' was not found.', + 14, + ], + [ + 'Invalid link params in link() call: Unable to pass parameters to action \':Test:implicit\', missing corresponding method in PHPStan\TestApp\Presenters\TestPresenter.', + 18, + ], + [ + 'Invalid link params in link() call: Passed more parameters than method PHPStan\TestApp\Presenters\TestPresenter::actionWithParam() expects.', + 26, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\TestPresenter::actionWithParam() must be string, null given.', + 28, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\TestPresenter::actionWithParam() must be string, null given.', + 29, + ], + [ + 'Invalid link destination \'this!\' in link() call.', + 31, + ], + [ + 'Invalid link destination \'signal!\' in link() call.', + 32, + ], + ]); + } + +} diff --git a/tests/Rule/Nette/PresenterLinksRuleTest.php b/tests/Rule/Nette/PresenterLinksRuleTest.php new file mode 100644 index 0000000..f5d3341 --- /dev/null +++ b/tests/Rule/Nette/PresenterLinksRuleTest.php @@ -0,0 +1,127 @@ + + */ +class PresenterLinksRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new PresenterLinksRule( + new LinkChecker( + new PresenterResolver( + ['*' => 'PHPStan\TestApp\Presenters\*\*Presenter'], + new ContainerResolver(null), + self::getContainer()->getByType(ReflectionProvider::class) + ), + self::getContainer()->getByType(ReflectionProvider::class) + ) + ); + } + + public function testRuleForPresenter(): void + { + require_once __DIR__ . '/../../TestApp/autoload.php'; + $this->analyse([__DIR__ . '/data/links-presenter.php'], [ + [ + 'Invalid link destination \'***\' in link() call.', + 5, + ], + [ + 'Invalid link destination \':Unknown:default\' in link() call: Cannot load presenter \'Unknown\', class \'PHPStan\TestApp\Presenters\UnknownPresenter\' was not found.', + 12, + ], + [ + 'Invalid link params in link() call: Unable to pass parameters to action \':Test:implicit\', missing corresponding method in PHPStan\TestApp\Presenters\TestPresenter.', + 15, + ], + [ + 'Invalid link params in link() call: Unable to pass parameters to action \':Test:implicit\', missing corresponding method in PHPStan\TestApp\Presenters\TestPresenter.', + 16, + ], + [ + 'Invalid link params in link() call: Passed more parameters than method PHPStan\TestApp\Presenters\TestPresenter::actionWithParam() expects.', + 24, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\TestPresenter::actionWithParam() must be string, null given.', + 25, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\TestPresenter::actionWithParam() must be string, null given.', + 26, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\TestPresenter::actionWithParam() must be string, null given.', + 27, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\CurrentModule\CurrentPresenter::actionWithParam() must be int, null given.', + 30, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\CurrentModule\CurrentPresenter::actionWithParam() must be int, null given.', + 31, + ], + [ + 'Invalid link params in link() call: Argument $param passed to PHPStan\TestApp\Presenters\CurrentModule\CurrentPresenter::actionWithParam() must be int, null given.', + 32, + ], + [ + 'Invalid link destination \'unknown!\' in link() call: Unknown signal \'unknown\', missing handler PHPStan\TestApp\Presenters\CurrentModule\CurrentPresenter::handleUnknown()', + 37, + ], + [ + 'Invalid link destination \'subComponent-unknown!\' in link() call: Unknown signal \'unknown\', missing handler PHPStan\TestApp\Components\CurrentComponent::handleUnknown()', + 38, + ], + [ + 'Invalid link destination \'!\' in link() call.', + 39, + ], + [ + 'Invalid link destination \'*!\' in link() call.', + 40, + ], + [ + 'Invalid link destination \'***\' in lazyLink() call.', + 42, + ], + [ + 'Invalid link destination \'***\' in isLinkCurrent() call.', + 43, + ], + [ + 'Invalid link destination \'***\' in redirect() call.', + 44, + ], + [ + 'Invalid link destination \'***\' in redirectPermanent() call.', + 45, + ], + [ + 'Invalid link destination \'***\' in forward() call.', + 46, + ], + [ + 'Invalid link destination \'***\' in canonicalize() call.', + 47, + ], + [ + 'Link check failed: Cannot analyse relative destination \'Test:default\' for abstract class Nette\Application\UI\Presenter.', + 58, + ], + ]); + } + +} diff --git a/tests/Rule/Nette/data/links-component.php b/tests/Rule/Nette/data/links-component.php new file mode 100644 index 0000000..505c371 --- /dev/null +++ b/tests/Rule/Nette/data/links-component.php @@ -0,0 +1,34 @@ +link('***'); + +$component->link('Test'); +$component->link(':Test'); + +$component->link('this'); +$component->link('signal'); +$component->link('subComponent-signal'); +$component->link('unknown'); +$component->link('subComponent-unknown'); +$component->link('genericComponent-unknown'); +$component->link('unknownComponent-signal'); + +$component->link('this!'); +$component->link('signal!'); +$component->link('subComponent-signal!'); +$component->link('unknown!'); +$component->link('subComponent-unknown!'); +$component->link('genericComponent-unknown!'); +$component->link('unknownComponent-signal!'); + +$component->link('withParam'); +$component->link('withParam', null); +$component->link('withParam', [null]); +$component->link('withParam', ['param' => null]); + +$component->lazyLink('***'); +$component->isLinkCurrent('***'); +if(false) {$component->redirect('***');} +if(false) {$component->redirectPermanent('***');} diff --git a/tests/Rule/Nette/data/links-linkGenerator.php b/tests/Rule/Nette/data/links-linkGenerator.php new file mode 100644 index 0000000..d49b3fa --- /dev/null +++ b/tests/Rule/Nette/data/links-linkGenerator.php @@ -0,0 +1,34 @@ +link('***'); + +$linkGenerator->link('this'); +$linkGenerator->link('Test'); +$linkGenerator->link('Test:'); +$linkGenerator->link('Test:default'); + +$linkGenerator->link(':Test:default'); + +$linkGenerator->link('Unknown:default'); + +$linkGenerator->link('Test:implicit'); +$linkGenerator->link('Test:implicit', 'paramValue'); +$linkGenerator->link('Test:implicit', ['paramValue']); +$linkGenerator->link('Test:implicit', ['param' => 'paramValue']); + +$linkGenerator->link('Test:withParam', ['paramValue']); +$linkGenerator->link('Test:withParam', ['param' => 'paramValue']); + +$linkGenerator->link('Test:withParam'); +$linkGenerator->link('Test:withParam', 'paramValue'); +$linkGenerator->link('Test:withParam', ['paramValue', 'paramValue']); +$linkGenerator->link('Test:withParam', null); +$linkGenerator->link('Test:withParam', [null]); +$linkGenerator->link('Test:withParam', ['param' => null]); + +$linkGenerator->link('this!'); +$linkGenerator->link('signal!'); + +$linkGenerator->link('Test:implicit'); diff --git a/tests/Rule/Nette/data/links-presenter.php b/tests/Rule/Nette/data/links-presenter.php new file mode 100644 index 0000000..ba5e16c --- /dev/null +++ b/tests/Rule/Nette/data/links-presenter.php @@ -0,0 +1,58 @@ +link('***'); + +$presenter->link('this'); +$presenter->link('Test:'); +$presenter->link('Test:default'); +$presenter->link('default'); + +$presenter->link(':Unknown:default'); + +$presenter->link(':Test:implicit'); +$presenter->link(':Test:implicit', 'paramValue'); +$presenter->link(':Test:implicit', ['paramValue']); +$presenter->link(':Test:implicit', ['param' => 'paramValue']); + +$presenter->link(':Test:withParam', 'paramValue'); +$presenter->link(':Test:withParam', ['paramValue']); +$presenter->link(':Test:withParam', ['param' => 'paramValue']); + +$presenter->link(':Test:withParam'); +$presenter->link(':Test:withParam', 'paramValue', 'paramValue'); +$presenter->link(':Test:withParam', null); +$presenter->link(':Test:withParam', [null]); +$presenter->link(':Test:withParam', ['param' => null]); + +$presenter->link('withParam'); +$presenter->link('withParam', null); +$presenter->link('withParam', [null]); +$presenter->link('withParam', ['param' => null]); + +$presenter->link('this!'); +$presenter->link('signal!'); +$presenter->link('subComponent-signal!'); +$presenter->link('unknown!'); +$presenter->link('subComponent-unknown!'); +$presenter->link('!'); +$presenter->link('*!'); + +$presenter->lazyLink('***'); +$presenter->isLinkCurrent('***'); +if(false) {$presenter->redirect('***');} +if(false) {$presenter->redirectPermanent('***');} +if(false) {$presenter->forward('***');} +if(false) {$presenter->canonicalize('***');} + +$presenter->isLinkCurrent(':Test:*'); +$presenter->isLinkCurrent('Test:*'); +$presenter->isLinkCurrent('*'); + +$presenter->link(':Test:implicit'); + +$abstractPresenter = $presenter->getPresenter(); +$abstractPresenter->link('this'); +$abstractPresenter->link(':Test:default'); +$abstractPresenter->link('Test:default'); diff --git a/tests/TestApp/Components/CurrentComponent.php b/tests/TestApp/Components/CurrentComponent.php new file mode 100644 index 0000000..9bae2fb --- /dev/null +++ b/tests/TestApp/Components/CurrentComponent.php @@ -0,0 +1,28 @@ + [ + 0 => ['presenterFactory'], + ], + ]; + + protected function createServicePresenterFactory(): IPresenterFactory + { + $service = new PresenterFactory(); + $service->setMapping(['*' => 'PHPStan\TestApp\Presenters\*\*Presenter']); + return $service; + } + +} diff --git a/tests/TestApp/autoload.php b/tests/TestApp/autoload.php new file mode 100644 index 0000000..f1a721f --- /dev/null +++ b/tests/TestApp/autoload.php @@ -0,0 +1,9 @@ +