diff --git a/extension.neon b/extension.neon index 4ecda48db..4e0e06004 100644 --- a/extension.neon +++ b/extension.neon @@ -262,11 +262,6 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension - - - class: NunoMaduro\Larastan\ReturnTypes\Helpers\ViewExtension - tags: - - phpstan.broker.dynamicFunctionReturnTypeExtension - - class: NunoMaduro\Larastan\ReturnTypes\Helpers\ValidatorExtension tags: @@ -442,6 +437,21 @@ services: - class: NunoMaduro\Larastan\LarastanStubFilesExtension tags: [phpstan.stubFilesExtension] + + - + class: NunoMaduro\Larastan\Rules\UnusedViewsRule + tags: + - phpstan.rules.rule + + - + class: NunoMaduro\Larastan\Collectors\UsedViewFunctionCollector + tags: + - phpstan.collector + + - + class: NunoMaduro\Larastan\Collectors\UsedEmailViewCollector + tags: + - phpstan.collector rules: - NunoMaduro\Larastan\Rules\RelationExistenceRule - NunoMaduro\Larastan\Rules\UselessConstructs\NoUselessWithFunctionCallsRule diff --git a/src/Collectors/UsedEmailViewCollector.php b/src/Collectors/UsedEmailViewCollector.php new file mode 100644 index 000000000..7d3ae1417 --- /dev/null +++ b/src/Collectors/UsedEmailViewCollector.php @@ -0,0 +1,54 @@ + */ +final class UsedEmailViewCollector implements Collector +{ + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + /** @param Node\Expr\MethodCall $node */ + public function processNode(Node $node, Scope $scope): ?string + { + $name = $node->name; + + if (! $name instanceof Identifier) { + return null; + } + + if ($name->name !== 'markdown') { + return null; + } + + if (count($node->getArgs()) === 0) { + return null; + } + + $class = $node->var; + + if (! (new ObjectType(Mailable::class))->isSuperTypeOf($scope->getType($class))->yes()) { + return null; + } + + $template = $node->getArgs()[0]->value; + + if (! $template instanceof Node\Scalar\String_) { + return null; + } + + return ViewName::normalize($template->value); + } +} diff --git a/src/Collectors/UsedViewFunctionCollector.php b/src/Collectors/UsedViewFunctionCollector.php new file mode 100644 index 000000000..2427ab6d4 --- /dev/null +++ b/src/Collectors/UsedViewFunctionCollector.php @@ -0,0 +1,49 @@ + */ +final class UsedViewFunctionCollector implements Collector +{ + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + /** @param Node\Expr\FuncCall $node */ + public function processNode(Node $node, Scope $scope): ?string + { + $funcName = $node->name; + + if (! $funcName instanceof Node\Name) { + return null; + } + + $funcName = $scope->resolveName($funcName); + + if ($funcName !== 'view') { + return null; + } + + // TODO: maybe make sure this function is coming from Laravel + + if (count($node->getArgs()) < 1) { + return null; + } + + $template = $node->getArgs()[0]->value; + + if (! $template instanceof Node\Scalar\String_) { + return null; + } + + return ViewName::normalize($template->value); + } +} diff --git a/src/ReturnTypes/Helpers/ViewExtension.php b/src/ReturnTypes/Helpers/ViewExtension.php deleted file mode 100644 index 030271ded..000000000 --- a/src/ReturnTypes/Helpers/ViewExtension.php +++ /dev/null @@ -1,42 +0,0 @@ -getName() === 'view'; - } - - /** - * {@inheritdoc} - */ - public function getTypeFromFunctionCall( - FunctionReflection $functionReflection, - FuncCall $functionCall, - Scope $scope - ): Type { - if (count($functionCall->getArgs()) === 0) { - return new ObjectType(\Illuminate\Contracts\View\Factory::class); - } - - return new ObjectType(\Illuminate\View\View::class); - } -} diff --git a/src/Rules/UnusedViewsRule.php b/src/Rules/UnusedViewsRule.php new file mode 100644 index 000000000..f477c43f6 --- /dev/null +++ b/src/Rules/UnusedViewsRule.php @@ -0,0 +1,60 @@ + */ +final class UnusedViewsRule implements Rule +{ + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $usedViews = array_unique(array_merge(...array_values($node->get(UsedViewFunctionCollector::class)), ...array_values($node->get(UsedEmailViewCollector::class)))); + + $allViews = array_map(function (SplFileInfo $file) { + return $file->getPathname(); + }, array_filter(File::allFiles(resource_path('views')), function (SplFileInfo $file) { + return ! str_contains($file->getPathname(), 'views/vendor') && $file->getExtension() === 'php' && str_ends_with($file->getFilename(), '.blade.php'); + })); + + $existingViews = []; + + /** @var Factory $view */ + $view = view(); + + foreach ($usedViews as $viewName) { + // Not existing views are reported with `view-string` type + if ($view->exists($viewName)) { + $existingViews[] = $view->getFinder()->find($viewName); + } + } + + $unusedViews = array_diff($allViews, $existingViews); + + $errors = []; + foreach ($unusedViews as $file) { + $errors[] = RuleErrorBuilder::message('This view is not used in the project.') + ->file($file) + ->line(0) + ->build(); + } + + return $errors; + } +} diff --git a/stubs/Contracts/View.stub b/stubs/Contracts/View.stub new file mode 100644 index 000000000..c501422a1 --- /dev/null +++ b/stubs/Contracts/View.stub @@ -0,0 +1,9 @@ +|array $data * @param array $mergeData - * @return mixed + * @return ($view is null ? \Illuminate\Contracts\View\Factory : \Illuminate\Contracts\View\View) */ function view($view = null, $data = [], $mergeData = []) { diff --git a/tests/Rules/Data/FooController.php b/tests/Rules/Data/FooController.php new file mode 100644 index 000000000..d21e7d02b --- /dev/null +++ b/tests/Rules/Data/FooController.php @@ -0,0 +1,37 @@ +markdown('emails.orders.shipped'); + } + + public function foo(): self + { + return $this->markdown('home'); + } +} diff --git a/tests/Rules/UnusedViewsRuleTest.php b/tests/Rules/UnusedViewsRuleTest.php new file mode 100644 index 000000000..f80a3678a --- /dev/null +++ b/tests/Rules/UnusedViewsRuleTest.php @@ -0,0 +1,40 @@ + */ +class UnusedViewsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new UnusedViewsRule; + } + + protected function getCollectors(): array + { + return [ + new UsedViewFunctionCollector, + new UsedEmailViewCollector, + ]; + } + + public function testRule(): void + { + $this->analyse([__DIR__.'/Data/FooController.php'], [ + [ + 'This view is not used in the project.', + 00, + ], + [ + 'This view is not used in the project.', + 00, + ], + ]); + } +} diff --git a/tests/Type/data/view.php b/tests/Type/data/view.php index becf3f9a6..25a42b58c 100644 --- a/tests/Type/data/view.php +++ b/tests/Type/data/view.php @@ -4,6 +4,7 @@ use function PHPStan\Testing\assertType; +assertType('Illuminate\Contracts\View\Factory', view()); assertType('Illuminate\View\View', view('foo')); assertType('Illuminate\View\View', view('foo')->with('bar', 'baz')); assertType('Illuminate\View\View', view('foo')->withFoo('bar'));