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
60 changes: 39 additions & 21 deletions src/Rule/Rules/Composer/Psr4NamespaceRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
namespace Boundwize\StructArmed\Rule\Rules\Composer;

use Boundwize\StructArmed\Analyser\ClassNode;
use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Composer\Psr4PathResolver;
use Boundwize\StructArmed\Rule\ProjectRuleInterface;
use Boundwize\StructArmed\Rule\RuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;
use Boundwize\StructArmed\Util\Path;

use function array_key_exists;
use function array_key_first;
use function array_unique;
use function arsort;
use function dirname;
use function file_exists;
Expand All @@ -25,8 +28,10 @@
use function strlen;
use function substr;

final class Psr4NamespaceRule implements RuleInterface
final class Psr4NamespaceRule implements RuleInterface, ProjectRuleInterface
{
private ?string $projectBasePath = null;

/** @var array<string, array<string, list<string>>> */
private array $mappingsByBasePath = [];

Expand All @@ -44,6 +49,17 @@ public function appliesTo(ClassNode $classNode): bool
return $classNode->isInLayer($this->layer);
}

/**
* Records the analysed project root so PSR-4 paths pointing outside it
* (e.g. "../shared/src/") are still checked; project rules run before class rules.
*/
public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation
{
$this->projectBasePath = Path::normalise($basePath, canonicalise: true);

return null;
}

public function evaluate(ClassNode $classNode): ?RuleViolation
{
$expectedClassNames = $this->expectedClassNames($classNode->file);
Expand Down Expand Up @@ -71,37 +87,39 @@ className: $classNode->className,
*/
private function expectedClassNames(string $file): array
{
$basePath = $this->basePathFor($file);

if ($basePath === null) {
return [];
}
$basePaths = array_unique([$this->projectBasePath, $this->basePathFor($file)]);

$file = Path::normalise($file, canonicalise: true);

$candidates = [];

foreach ($this->mappingsFor($basePath) as $namespace => $paths) {
foreach ($paths as $path) {
$prefix = Path::normalise(Path::resolve($path, $basePath), canonicalise: true);
foreach ($basePaths as $basePath) {
if ($basePath === null) {
continue;
}

if (! str_starts_with($file, $prefix . '/')) {
continue;
}
foreach ($this->mappingsFor($basePath) as $namespace => $paths) {
foreach ($paths as $path) {
$prefix = Path::normalise(Path::resolve($path, $basePath), canonicalise: true);

$relativeClass = substr($file, strlen($prefix) + 1);
if (! str_starts_with($file, $prefix . '/')) {
continue;
}

if (! str_ends_with($relativeClass, '.php')) {
continue;
}
$relativeClass = substr($file, strlen($prefix) + 1);

if (! str_ends_with($relativeClass, '.php')) {
continue;
}

$relativeClass = substr($relativeClass, 0, -4);
$relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass);
$relativeClass = str_replace('/', '\\', $relativeClass);
$relativeClass = substr($relativeClass, 0, -4);
$relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass);
$relativeClass = str_replace('/', '\\', $relativeClass);

$className = $namespace . ltrim($relativeClass, '\\');
$className = $namespace . ltrim($relativeClass, '\\');

$candidates[$className] = max($candidates[$className] ?? 0, strlen($prefix));
$candidates[$className] = max($candidates[$className] ?? 0, strlen($prefix));
}
}
}

Expand Down
39 changes: 39 additions & 0 deletions tests/Rule/Composer/Psr4NamespaceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Boundwize\StructArmed\Tests\Rule\Composer;

use Boundwize\StructArmed\Analyser\ClassNode;
use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Rule\Rules\Composer\Psr4NamespaceRule;
use Boundwize\StructArmed\Rule\RuleViolation;
use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait;
Expand Down Expand Up @@ -74,6 +75,44 @@ public function testFailsWhenClassDoesNotMatchAbsoluteComposerPsr4Path(): void
$this->assertSame('Class [Wrong\\Foo] must match PSR-4 class [App\\Foo]', $violation->message);
}

public function testFailsForPsr4PathOutsideProjectDirectory(): void
{
$rootPath = $this->makeTemporaryDirectory('structarmed-psr4-outside-project');
mkdir($rootPath . '/project');
mkdir($rootPath . '/shared/src', 0777, true);

file_put_contents($rootPath . '/project/composer.json', json_encode([
'autoload' => [
'psr-4' => [
'App\\' => '../shared/src/',
],
],
]));

$file = $rootPath . '/shared/src/Foo.php';
file_put_contents($file, '<?php namespace Wrong; class Foo {}');

$psr4NamespaceRule = new Psr4NamespaceRule('Source');

$this->assertNotInstanceOf(
RuleViolation::class,
$psr4NamespaceRule->evaluateProject($rootPath . '/project', Architecture::define())
);

$violation = $psr4NamespaceRule->evaluate($this->makeNode('Wrong\\Foo', $file));

$this->assertInstanceOf(RuleViolation::class, $violation);
$this->assertSame('Class [Wrong\\Foo] must match PSR-4 class [App\\Foo]', $violation->message);

mkdir($rootPath . '/shared/src/Sub');
file_put_contents($rootPath . '/shared/src/Sub/Bar.php', '<?php namespace App\Sub; class Bar {}');

$this->assertNotInstanceOf(
RuleViolation::class,
$psr4NamespaceRule->evaluate($this->makeNode('App\\Sub\\Bar', $rootPath . '/shared/src/Sub/Bar.php'))
);
}

#[DataProvider('nonClassKindProvider')]
public function testViolationMessageNamesTheClassLikeKind(
string $expectedKind,
Expand Down
Loading