Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,12 @@ jobs:
name: Tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [ '8.2', '8.3', '8.4', '8.5' ]
composer-dependency-version: ['']
composer-minimum-stability: ['stable']
include:
# Lowest dependencies on minimum supported PHP version
- php: '8.2'
composer-dependency-version: 'lowest'

# Highest dev dependencies
- php: '8.5'
composer-minimum-stability: 'dev'
Expand All @@ -83,7 +80,7 @@ jobs:
run: symfony composer config minimum-stability ${{ matrix.composer-minimum-stability }}

- name: Install Composer dependencies
run: symfony composer update --prefer-dist --no-interaction --no-progress ${{ matrix.composer-dependency-version == 'lowest' && '--prefer-lowest' || '' }}
run: symfony composer update --prefer-dist --no-interaction --no-progress

- name: Run PHPUnit tests
run: symfony composer run test
113 changes: 111 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,115 @@ To install the PHPStan rules for Symfony UX, you can use Composer:
composer require --dev kocal/phpstan-symfony-ux
```

## Configuration
## TwigComponent Rules

TODO
### ForbiddenAttributesPropertyRule

Forbid the use of the `$attributes` property in Twig Components, which can lead to confusion when using `{{ attributes }}` (an instance of `ComponentAttributes` that is automatically injected) in Twig templates.

```yaml
rules:
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenAttributesPropertyRule
```

```php
// src/Twig/Components/Alert.php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class Alert
{
public $attributes;
}
```

```php
// src/Twig/Components/Alert.php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent(attributesVar: 'customAttributes')]
final class Alert
{
public $customAttributes;
}
```

:x:

<br>

```php
// src/Twig/Components/Alert.php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class Alert
{
}
```

```php
// src/Twig/Components/Alert.php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class Alert
{
public $customAttributes;
}
```

:+1:

<br>

### ForbiddenClassPropertyRule

Forbid the use of the `$class` property in Twig Components, as it is considered a bad practice to manipulate CSS classes directly in components.
Use `{{ attributes }}` or `{{ attributes.defaults({ class: '...' }) }}` in your Twig templates instead.

```yaml
rules:
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenClassPropertyRule
```

```php
// src/Twig/Components/Alert.php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class Alert
{
public $class;
}
```

:x:

<br>

```php
// src/Twig/Components/Alert.php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class Alert
{
}
```

:+1:

<br>
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
}
],
"scripts": {
"qa-fix": [
"@cs-fix",
"@phpstan",
"@test"
],
"phpstan": "vendor/bin/phpstan analyze",
"test": "vendor/bin/phpunit",
"cs": "vendor/bin/ecs check",
Expand All @@ -30,7 +35,7 @@
"phpstan/phpstan": "^2.1.13"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpunit/phpunit": "^11.1",
"symfony/ux-twig-component": "^2.0",
"symplify/easy-coding-standard": "^13.0"
},
Expand Down
49 changes: 49 additions & 0 deletions src/NodeAnalyzer/AttributeFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\NodeAnalyzer;

use PhpParser\Node\Attribute;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\ClassLike;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Property;

/**
* Heavily inspired by https://github.com/symplify/phpstan-rules/blob/main/src/NodeAnalyzer/AttributeFinder.php <3
*/
final class AttributeFinder
{
/**
* @return Attribute[]
*/
public static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array
{
$attributes = [];

foreach ($node->attrGroups as $attrGroup) {
$attributes = array_merge($attributes, $attrGroup->attrs);
}

return $attributes;
}

public static function findAttribute(ClassMethod | Property | ClassLike | Param $node, string $desiredAttributeClass): ?Attribute
{
$attributes = self::findAttributes($node);

foreach ($attributes as $attribute) {
if (! $attribute->name instanceof FullyQualified) {
continue;
}

if ($attribute->name->toString() === $desiredAttributeClass) {
return $attribute;
}
}

return null;
}
}
87 changes: 87 additions & 0 deletions src/Rules/TwigComponent/ForbiddenAttributesPropertyRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Rules\TwigComponent;

use Kocal\PHPStanSymfonyUX\NodeAnalyzer\AttributeFinder;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @implements Rule<Class_>
*/
final class ForbiddenAttributesPropertyRule implements Rule
{
public function __construct(
private ReflectionProvider $reflectionProvider,
) {
}

public function getNodeType(): string
{
return Class_::class;
}

public function processNode(Node $node, Scope $scope): array
{
if (! $asTwigComponent = AttributeFinder::findAttribute($node, AsTwigComponent::class)) {
return [];
}

if (! $attributesVarName = $this->getAttributesVarName($asTwigComponent)) {
return [];
}

if ($propertyAttributes = $node->getProperty($attributesVarName['name'])) {
return [
RuleErrorBuilder::message(
$attributesVarName['custom']
? sprintf('Using property "%s" in a Twig component is forbidden, it may lead to confusion with the "%s" attribute defined in #[AsTwigComponent].', $attributesVarName['name'], $attributesVarName['name'])
: sprintf('Using property "%s" in a Twig component is forbidden, it may lead to confusion with the default "attributes" Twig variable.', $attributesVarName['name'])
)
->identifier('SymfonyUX.TwigComponent.forbiddenAttributesProperty')
->line($propertyAttributes->getLine())
->tip('Consider renaming or removing this property to avoid conflicts with the Twig component attributes.')
->build(),

];
}

return [];
}

/**
* @return array{name: string, custom: bool}|null
*/
private function getAttributesVarName(Node\Attribute $attribute): ?array
{
foreach ($attribute->args as $arg) {
if ($arg->name && $arg->name->toString() === 'attributesVar') {
if ($arg->value instanceof Node\Scalar\String_) {
return [
'name' => $arg->value->value,
'custom' => true,
];
}
}
}

$reflAttribute = $this->reflectionProvider->getClass(AsTwigComponent::class);
foreach ($reflAttribute->getConstructor()->getOnlyVariant()->getParameters() as $reflParameter) {
if ($reflParameter->getName() === 'attributesVar' && $reflParameter->getDefaultValue()?->getConstantStrings()) {
return [
'name' => $reflParameter->getDefaultValue()->getConstantStrings()[0]->getValue(),
'custom' => false,
];
}
}

return null;
}
}
44 changes: 44 additions & 0 deletions src/Rules/TwigComponent/ForbiddenClassPropertyRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Rules\TwigComponent;

use Kocal\PHPStanSymfonyUX\NodeAnalyzer\AttributeFinder;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @implements Rule<Class_>
*/
final class ForbiddenClassPropertyRule implements Rule
{
public function getNodeType(): string
{
return Class_::class;
}

public function processNode(Node $node, Scope $scope): array
{
if (! AttributeFinder::findAttribute($node, AsTwigComponent::class)) {
return [];
}

if ($propertyClass = $node->getProperty('class')) {
return [
RuleErrorBuilder::message('Using a "class" property in a Twig component is forbidden, it is considered as an anti-pattern.')
->identifier('symfonyUX.twigComponent.forbiddenClassProperty')
->line($propertyClass->getLine())
->tip('Consider using {{ attributes }} to automatically render unknown properties as HTML attributes, such as "class". Learn more at https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes.')
->build(),

];
}

return [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule\Fixture;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class ComponentWithAttributesProperty
{
public string $attributes;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule\Fixture;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent(attributesVar: 'customAttributes')]
final class ComponentWithAttributesProperty
{
public string $customAttributes;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ForbiddenAttributesPropertyRule\Fixture;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class ComponentWithNoAttributesProperty
{
}
Loading