Skip to content

Commit

Permalink
feat: unused views collector rule
Browse files Browse the repository at this point in the history
  • Loading branch information
canvural committed Oct 23, 2022
1 parent 7bba7d3 commit ae6fd19
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 48 deletions.
20 changes: 15 additions & 5 deletions extension.neon
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/Collectors/UsedEmailViewCollector.php
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace NunoMaduro\Larastan\Collectors;

use Illuminate\Mail\Mailable;
use Illuminate\View\ViewName;
use PhpParser\Node;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\Type\ObjectType;

/** @implements Collector<Node\Expr\MethodCall, string> */
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);
}
}
49 changes: 49 additions & 0 deletions src/Collectors/UsedViewFunctionCollector.php
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace NunoMaduro\Larastan\Collectors;

use Illuminate\View\ViewName;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;

/** @implements Collector<Node\Expr\FuncCall, string> */
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);
}
}
42 changes: 0 additions & 42 deletions src/ReturnTypes/Helpers/ViewExtension.php

This file was deleted.

60 changes: 60 additions & 0 deletions src/Rules/UnusedViewsRule.php
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace NunoMaduro\Larastan\Rules;

use Illuminate\Support\Facades\File;
use Illuminate\View\Factory;
use NunoMaduro\Larastan\Collectors\UsedEmailViewCollector;
use NunoMaduro\Larastan\Collectors\UsedViewFunctionCollector;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\CollectedDataNode;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symfony\Component\Finder\SplFileInfo;

/** @implements Rule<CollectedDataNode> */
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;
}
}
9 changes: 9 additions & 0 deletions stubs/Contracts/View.stub
@@ -0,0 +1,9 @@
<?php

namespace Illuminate\Contracts\View;

interface Factory
{}

interface View
{}
2 changes: 1 addition & 1 deletion stubs/Helpers.stub
Expand Up @@ -126,7 +126,7 @@ function tap($value, $callback = null)
* @param view-string|null $view
* @param \Illuminate\Contracts\Support\Arrayable<array-key, mixed>|array<string, mixed> $data
* @param array<string, mixed> $mergeData
* @return mixed
* @return ($view is null ? \Illuminate\Contracts\View\Factory : \Illuminate\Contracts\View\View)
*/
function view($view = null, $data = [], $mergeData = [])
{
Expand Down
37 changes: 37 additions & 0 deletions tests/Rules/Data/FooController.php
@@ -0,0 +1,37 @@
<?php

namespace Tests\Rules\Data;

use Illuminate\Contracts\View\View;
use Illuminate\Mail\Mailable;

class FooController
{
public function existing(): View
{
return view('users.index');
}

public function existingNested(): View
{
return view('emails.orders.shipped');
}

public function notExisting(): View
{
return view('foo');
}
}

class FooEmail extends Mailable
{
public function build(): self
{
return $this->markdown('emails.orders.shipped');
}

public function foo(): self
{
return $this->markdown('home');
}
}
40 changes: 40 additions & 0 deletions tests/Rules/UnusedViewsRuleTest.php
@@ -0,0 +1,40 @@
<?php

namespace Rules;

use NunoMaduro\Larastan\Collectors\UsedEmailViewCollector;
use NunoMaduro\Larastan\Collectors\UsedViewFunctionCollector;
use NunoMaduro\Larastan\Rules\UnusedViewsRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/** @extends RuleTestCase<UnusedViewsRule> */
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,
],
]);
}
}
1 change: 1 addition & 0 deletions tests/Type/data/view.php
Expand Up @@ -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'));

0 comments on commit ae6fd19

Please sign in to comment.