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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"require": {
"php": "^8.2",
"composer-runtime-api": "^2.0",
"boundwize/jsonrecast": "^0.0.2",
"fidry/cpu-core-counter": "^1.3",
"nikic/php-parser": "^5.7"
},
Expand Down
2 changes: 1 addition & 1 deletion docs/assets/no-violation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/assets/structarmed-showoff.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Composer`.

| Rule | Constructor | Checks |
|---|---|---|
| `Psr4DirectoryExistsRule` | `new Psr4DirectoryExistsRule()` | `composer.json` exists, is valid JSON, and every PSR-4 source path exists on disk. |
| `Psr4DirectoryExistsRule` | `new Psr4DirectoryExistsRule()` | `composer.json` exists, is valid JSON, and every PSR-4 source path exists on disk. Supports `--fix` by removing mappings for missing directories. |
| `Psr4EmptyNamespacePrefixRule` | `new Psr4EmptyNamespacePrefixRule()` | `autoload` and `autoload-dev` PSR-4 mappings do not use an empty namespace prefix. |
| `Psr4NamespaceRule` | `new Psr4NamespaceRule(layer: 'Source')` | A class name matches the namespace expected from its PSR-4 path. |
| `Psr4RootPathRule` | `new Psr4RootPathRule()` | PSR-4 mappings do not point directly to the project root. |
Expand Down Expand Up @@ -91,7 +91,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`.

`classNamePattern` and `excludePattern` are regular expressions matched against the fully-qualified class name.

`Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustBeFinalRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically normalize invalid PHP opening tags, remove UTF-8 byte order marks, add the `final` class modifier, and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`.
`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustBeFinalRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, add the `final` class modifier, and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`.

## Layer Rules

Expand Down
31 changes: 31 additions & 0 deletions src/Rule/Fixer/JsonRecast/AbstractJsonRecastFixableRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Boundwize\StructArmed\Rule\Fixer\JsonRecast;

use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitor;
use Boundwize\StructArmed\Rule\FixableInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

abstract readonly class AbstractJsonRecastFixableRule implements FixableInterface
{
final public function fix(RuleViolation $ruleViolation): bool
{
return $this->fixerProcessor()->process(
$ruleViolation->file,
$this->createFixerVisitor($ruleViolation),
);
}

abstract protected function createFixerVisitor(RuleViolation $ruleViolation): NodeJsonVisitor;

private function fixerProcessor(): JsonRecastFixerProcessor
{
static $processor;

return $processor instanceof JsonRecastFixerProcessor
? $processor
: ($processor = new JsonRecastFixerProcessor());
}
}
203 changes: 203 additions & 0 deletions src/Rule/Fixer/JsonRecast/Composer/RemoveMissingPsr4PathVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

declare(strict_types=1);

namespace Boundwize\StructArmed\Rule\Fixer\JsonRecast\Composer;

use Boundwize\JsonRecast\Node\ArrayItemNode;
use Boundwize\JsonRecast\Node\ArrayNode;
use Boundwize\JsonRecast\Node\NodeJson;
use Boundwize\JsonRecast\Node\ObjectItemNode;
use Boundwize\JsonRecast\Node\ObjectNode;
use Boundwize\JsonRecast\Node\StringNode;
use Boundwize\JsonRecast\NodePath\NodeJsonPath;
use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitor;
use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract;
use Boundwize\StructArmed\Util\Path;

use function array_key_exists;
use function array_slice;
use function in_array;
use function is_dir;
use function strlen;
use function trim;

final class RemoveMissingPsr4PathVisitor extends NodeJsonVisitorAbstract
{
private const CHILD_CHANGED = 'child_changed';

/** @var list<string> */
private const AUTOLOAD_SECTIONS = ['autoload', 'autoload-dev'];

/** @var array<string, true> */
private array $changedContainerPathKeys = [];

public function __construct(
private readonly string $basePath,
) {
}

public function enterNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): ?int
{
if ($nodeJson instanceof ObjectItemNode && $this->isPsr4Mapping($nodeJsonPath)) {
if (
$nodeJson->value instanceof StringNode
&& ! $this->directoryExists($nodeJson->value->value)
) {
$this->markContainerChanged($nodeJsonPath);

return NodeJsonVisitor::REMOVE_NODE;
}

return null;
}

if (! $nodeJson instanceof ArrayItemNode || ! $this->isPsr4PathListItem($nodeJsonPath)) {
return null;
}

if (! $nodeJson->value instanceof StringNode || $this->directoryExists($nodeJson->value->value)) {
return null;
}

$this->markContainerChanged($this->parentPath($nodeJsonPath));

return NodeJsonVisitor::REMOVE_NODE;
}

public function leaveNode(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): null|int
{
if ($nodeJson instanceof ObjectNode || $nodeJson instanceof ArrayNode) {
$this->flagChangedContainer($nodeJson, $nodeJsonPath);

return null;
}

if (! $nodeJson instanceof ObjectItemNode) {
return null;
}

if ($this->isPsr4Section($nodeJson, $nodeJsonPath)) {
if (
! $nodeJson->value instanceof ObjectNode
|| $nodeJson->value->items !== []
|| ! $this->hasChangedChild($nodeJson->value)
) {
return null;
}

$this->markContainerChanged($nodeJsonPath);

return NodeJsonVisitor::REMOVE_NODE;
}

if ($this->isEmptyComposerAutoloadItem($nodeJson, $nodeJsonPath)) {
return NodeJsonVisitor::REMOVE_NODE;
}

if (! $this->isPsr4Mapping($nodeJsonPath)) {
return null;
}

if (
! $nodeJson->value instanceof ArrayNode
|| $nodeJson->value->items !== []
|| ! $this->hasChangedChild($nodeJson->value)
) {
return null;
}

$this->markContainerChanged($nodeJsonPath);

return NodeJsonVisitor::REMOVE_NODE;
}

private function isPsr4Section(ObjectItemNode $objectItemNode, NodeJsonPath $nodeJsonPath): bool
{
return $objectItemNode->key->value === 'psr-4'
&& $this->isComposerAutoloadPath($nodeJsonPath);
}

private function isEmptyComposerAutoloadItem(ObjectItemNode $objectItemNode, NodeJsonPath $nodeJsonPath): bool
{
return $nodeJsonPath->isRoot()
&& $this->isComposerAutoloadKey($objectItemNode->key->value)
&& $objectItemNode->value instanceof ObjectNode
&& $objectItemNode->value->items === []
&& $this->hasChangedChild($objectItemNode->value);
}

private function isPsr4Mapping(NodeJsonPath $nodeJsonPath): bool
{
return $nodeJsonPath->matches(['autoload', 'psr-4'])
|| $nodeJsonPath->matches(['autoload-dev', 'psr-4']);
}

private function isPsr4PathListItem(NodeJsonPath $nodeJsonPath): bool
{
$last = $nodeJsonPath->last();

return $last?->isArrayIndex() === true
&& $this->isPsr4MappingValuePath($this->parentPath($nodeJsonPath));
}

private function isPsr4MappingValuePath(NodeJsonPath $nodeJsonPath): bool
{
$last = $nodeJsonPath->last();

return $last?->isObjectKey() === true
&& $this->isPsr4Mapping($this->parentPath($nodeJsonPath));
}

private function isComposerAutoloadPath(NodeJsonPath $nodeJsonPath): bool
{
return $nodeJsonPath->matches(['autoload'])
|| $nodeJsonPath->matches(['autoload-dev']);
}

private function isComposerAutoloadKey(string $key): bool
{
return in_array($key, self::AUTOLOAD_SECTIONS, true);
}

private function directoryExists(string $path): bool
{
return is_dir(Path::resolve(Path::normalise(trim($path)), $this->basePath));
}

private function markContainerChanged(NodeJsonPath $nodeJsonPath): void
{
$this->changedContainerPathKeys[$this->pathKey($nodeJsonPath)] = true;
}

private function flagChangedContainer(NodeJson $nodeJson, NodeJsonPath $nodeJsonPath): void
{
if (! array_key_exists($this->pathKey($nodeJsonPath), $this->changedContainerPathKeys)) {
return;
}

$nodeJson->setAttribute(self::CHILD_CHANGED, true);
}

private function hasChangedChild(NodeJson $nodeJson): bool
{
return $nodeJson->getAttribute(self::CHILD_CHANGED) === true;
}

private function parentPath(NodeJsonPath $nodeJsonPath): NodeJsonPath
{
return new NodeJsonPath(array_slice($nodeJsonPath->segments(), 0, -1));
}

private function pathKey(NodeJsonPath $nodeJsonPath): string
{
$key = '';

foreach ($nodeJsonPath->segments() as $nodeJsonPathSegment) {
$value = (string) $nodeJsonPathSegment->value;
$key .= ($nodeJsonPathSegment->isObjectKey() ? 'o' : 'a') . strlen($value) . ':' . $value;
}

return $key;
}
}
38 changes: 38 additions & 0 deletions src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Boundwize\StructArmed\Rule\Fixer\JsonRecast;

use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitor;
use Boundwize\JsonRecast\Parser\ParseError;

use function file_get_contents;
use function file_put_contents;
use function is_file;

final readonly class JsonRecastFixerProcessor
{
public function process(string $file, NodeJsonVisitor $nodeJsonVisitor): bool
{
if (! is_file($file)) {
return false;
}

$json = (string) file_get_contents($file);

try {
$result = JsonRecast::traverse(
JsonRecast::parse($json),
$nodeJsonVisitor
);
} catch (ParseError) {
return false;
}

$fixedJson = JsonRecast::print($result);

return $fixedJson !== $json && file_put_contents($file, $fixedJson) !== false;
}
}
10 changes: 9 additions & 1 deletion src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Composer\Psr4PathResolver;
use Boundwize\StructArmed\Rule\Fixer\JsonRecast\AbstractJsonRecastFixableRule;
use Boundwize\StructArmed\Rule\Fixer\JsonRecast\Composer\RemoveMissingPsr4PathVisitor;
use Boundwize\StructArmed\Rule\ProjectRuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function dirname;
use function file_exists;
use function implode;
use function is_dir;
use function rtrim;
use function sprintf;

final readonly class Psr4DirectoryExistsRule implements ProjectRuleInterface
final readonly class Psr4DirectoryExistsRule extends AbstractJsonRecastFixableRule implements ProjectRuleInterface
{
public function __construct(
private Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(),
Expand Down Expand Up @@ -73,4 +76,9 @@ className: '',
layer: 'Source',
);
}

protected function createFixerVisitor(RuleViolation $ruleViolation): RemoveMissingPsr4PathVisitor
{
return new RemoveMissingPsr4PathVisitor(dirname($ruleViolation->file));
}
}
Loading
Loading