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
24 changes: 17 additions & 7 deletions src/Rule/Rules/Composer/Psr4NamespaceRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
use Boundwize\StructArmed\Rule\RuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function array_column;
use function dirname;
use function file_exists;
use function in_array;
use function ltrim;
use function preg_replace;
use function realpath;
Expand All @@ -21,6 +23,7 @@
use function str_starts_with;
use function strlen;
use function substr;
use function usort;

final class Psr4NamespaceRule implements RuleInterface
{
Expand All @@ -40,17 +43,17 @@ public function appliesTo(ClassNode $classNode): bool

public function evaluate(ClassNode $classNode): ?RuleViolation
{
$expectedClassName = $this->expectedClassName($classNode->file);
$expectedClassNames = $this->expectedClassNames($classNode->file);

if ($expectedClassName === null || $classNode->className === $expectedClassName) {
if ($expectedClassNames === [] || in_array($classNode->className, $expectedClassNames, true)) {
return null;
}

return new RuleViolation(
message: sprintf(
'Class [%s] must match PSR-4 class [%s]',
$classNode->className,
$expectedClassName
$expectedClassNames[0]
),
file: $classNode->file,
line: $classNode->line,
Expand All @@ -59,16 +62,21 @@ className: $classNode->className,
);
}

private function expectedClassName(string $file): ?string
/**
* @return list<string>
*/
private function expectedClassNames(string $file): array
{
$basePath = $this->basePathFor($file);

if ($basePath === null) {
return null;
return [];
}

$file = $this->normalisePath($file);

$candidates = [];

foreach ($this->mappingsFor($basePath) as $namespace => $paths) {
foreach ($paths as $path) {
$prefix = $this->normalisePath($basePath . '/' . $path);
Expand All @@ -87,11 +95,13 @@ private function expectedClassName(string $file): ?string
$relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass);
$relativeClass = str_replace('/', '\\', $relativeClass);

return $namespace . ltrim($relativeClass, '\\');
$candidates[] = ['length' => strlen($prefix), 'name' => $namespace . ltrim($relativeClass, '\\')];
}
}

return null;
usort($candidates, static fn(array $a, array $b): int => $b['length'] <=> $a['length']);

return array_column($candidates, 'name');
}

private function basePathFor(string $file): ?string
Expand Down
105 changes: 105 additions & 0 deletions tests/Rule/Composer/Psr4NamespaceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,111 @@ public function testCachesMappingsPerBasePath(): void
);
}

public function testSelectsLongestPrefixMatchNotFirstDeclared(): void
{
$basePath = $this->makeTemporaryDirectory('structarmed-psr4-longest-prefix');
mkdir($basePath . '/src/legacy', 0777, true);

file_put_contents($basePath . '/composer.json', json_encode([
'autoload' => [
'psr-4' => [
'App\\' => 'src/',
'Legacy\\' => 'src/legacy/',
],
],
]));

$file = $basePath . '/src/legacy/Thing.php';
file_put_contents($file, '<?php namespace Legacy; class Thing {}');

$psr4NamespaceRule = new Psr4NamespaceRule('Source');

$this->assertNotInstanceOf(
RuleViolation::class,
$psr4NamespaceRule->evaluate($this->makeNode('Legacy\\Thing', $file))
);
}

public function testSelectsLongestPrefixMatchNotLastDeclared(): void
{
$basePath = $this->makeTemporaryDirectory('structarmed-psr4-longest-prefix-flipped');
mkdir($basePath . '/src/legacy', 0777, true);

file_put_contents($basePath . '/composer.json', json_encode([
'autoload' => [
'psr-4' => [
'Legacy\\' => 'src/legacy/',
'App\\' => 'src/',
],
],
]));

$file = $basePath . '/src/legacy/Thing.php';
file_put_contents($file, '<?php namespace Legacy; class Thing {}');

$psr4NamespaceRule = new Psr4NamespaceRule('Source');

$this->assertNotInstanceOf(
RuleViolation::class,
$psr4NamespaceRule->evaluate($this->makeNode('Legacy\\Thing', $file))
);
}

public function testAllowsShortPrefixNamespaceWhenFileAlsoMatchesLongerPrefix(): void
{
$basePath = $this->makeTemporaryDirectory('structarmed-psr4-overlapping-prefix');
mkdir($basePath . '/src/Legacy', 0777, true);

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

$file = $basePath . '/src/Legacy/Foo.php';
file_put_contents($file, '<?php namespace App\Legacy; class Foo {}');

$psr4NamespaceRule = new Psr4NamespaceRule('Source');

$this->assertNotInstanceOf(
RuleViolation::class,
$psr4NamespaceRule->evaluate($this->makeNode('App\\Legacy\\Foo', $file))
);
}

public function testAllowsEitherNamespaceWhenTwoNamespacesMapToSameDirectory(): void
{
$basePath = $this->makeTemporaryDirectory('structarmed-psr4-same-length-prefix');
mkdir($basePath . '/src/Bar', 0777, true);

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

$file = $basePath . '/src/Bar/Baz.php';
file_put_contents($file, '<?php namespace App\Bar; class Baz {}');

$psr4NamespaceRule = new Psr4NamespaceRule('Source');

$this->assertNotInstanceOf(
RuleViolation::class,
$psr4NamespaceRule->evaluate($this->makeNode('App\\Bar\\Baz', $file))
);

$this->assertNotInstanceOf(
RuleViolation::class,
$psr4NamespaceRule->evaluate($this->makeNode('Foo\\Bar\\Baz', $file))
);
}

private function makeNode(
string $className,
string $file,
Expand Down
Loading