Skip to content
Closed
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
77 changes: 54 additions & 23 deletions src/Rule/Rules/Composer/Psr4NamespaceRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
use function array_key_first;
use function array_unique;
use function arsort;
use function basename;
use function dirname;
use function file_exists;
use function ltrim;
use function max;
use function preg_replace;
use function rtrim;
use function sprintf;
use function str_ends_with;
use function str_replace;
Expand All @@ -38,6 +40,9 @@ final class Psr4NamespaceRule implements RuleInterface, ProjectRuleInterface
/** @var array<string, string|null> */
private array $basePathByDirectory = [];

/** @var array<string, array<string, int>> */
private array $namespaceCandidatesByDirectory = [];

public function __construct(
private readonly string $layer,
private readonly Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(),
Expand All @@ -55,7 +60,8 @@ public function appliesTo(ClassNode $classNode): bool
*/
public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation
{
$this->projectBasePath = Path::normalise($basePath, canonicalise: true);
$this->projectBasePath = Path::normalise($basePath, canonicalise: true);
$this->namespaceCandidatesByDirectory = [];

return null;
}
Expand Down Expand Up @@ -87,45 +93,59 @@ className: $classNode->className,
*/
private function expectedClassNames(string $file): array
{
$basePaths = array_unique([$this->projectBasePath, $this->basePathFor($file)]);

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

if (! str_ends_with($file, '.php')) {
return [];
}

$directory = dirname($file);
$shortName = basename($file, '.php');
$shortName = (string) preg_replace('/\.class$/i', '', $shortName);

$candidates = [];

foreach ($this->namespaceCandidatesFor($directory, $file) as $namespace => $prefixLength) {
$candidates[$namespace . $shortName] = $prefixLength;
}

return $candidates;
}

/** @return array<string, int> */
private function namespaceCandidatesFor(string $directory, string $file): array
{
if (isset($this->namespaceCandidatesByDirectory[$directory])) {
return $this->namespaceCandidatesByDirectory[$directory];
}

$basePaths = array_unique([$this->projectBasePath, $this->basePathFor($file)]);
$directoryWithSlash = rtrim($directory, '/') . '/';
$candidates = [];

foreach ($basePaths as $basePath) {
if ($basePath === null) {
continue;
}

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

if (! str_starts_with($file, $prefix . '/')) {
foreach ($this->mappingsFor($basePath) as $namespace => $prefixes) {
foreach ($prefixes as $prefix) {
if (! str_starts_with($directoryWithSlash, $prefix . '/')) {
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);

$className = $namespace . ltrim($relativeClass, '\\');
$relativeNamespace = substr($directoryWithSlash, strlen($prefix) + 1);
$relativeNamespace = str_replace('/', '\\', $relativeNamespace);
$candidate = $namespace . ltrim($relativeNamespace, '\\');

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

arsort($candidates);

return $candidates;
return $this->namespaceCandidatesByDirectory[$directory] = $candidates;
}

private function basePathFor(string $file): ?string
Expand Down Expand Up @@ -173,8 +193,19 @@ private function basePathFor(string $file): ?string
*/
private function mappingsFor(string $basePath): array
{
$this->mappingsByBasePath[$basePath] ??= $this->psr4PathResolver->namespacePaths($basePath);
if (isset($this->mappingsByBasePath[$basePath])) {
return $this->mappingsByBasePath[$basePath];
}

$mappings = [];

// Resolve directory prefixes once per composer root, instead of for every class.
foreach ($this->psr4PathResolver->namespacePaths($basePath) as $namespace => $paths) {
foreach ($paths as $path) {
$mappings[$namespace][] = Path::normalise(Path::resolve($path, $basePath), canonicalise: true);
}
}

return $this->mappingsByBasePath[$basePath];
return $this->mappingsByBasePath[$basePath] = $mappings;
}
}
51 changes: 51 additions & 0 deletions tests/Rule/Composer/Psr4NamespaceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,57 @@ public function testAllowsEitherNamespaceWhenTwoNamespacesMapToSameDirectory():
);
}

public function testReusesDirectoryCandidatesWithLongestPrefixFirst(): void
{
$basePath = $this->makeTemporaryDirectory('structarmed-psr4-directory-candidates');
mkdir($basePath . '/src/Legacy', 0777, true);
file_put_contents($basePath . '/composer.json', json_encode([
'autoload' => [
'psr-4' => [
'App\\' => 'src/',
'Legacy\\' => 'src/Legacy/',
],
],
]));

$psr4NamespaceRule = new Psr4NamespaceRule('Source');

foreach (['Foo', 'Bar'] as $name) {
$file = $basePath . '/src/Legacy/' . $name . '.class.php';
file_put_contents($file, '<?php');

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

$this->assertInstanceOf(RuleViolation::class, $violation);
$this->assertSame('Class [Wrong] must match PSR-4 class [Legacy\\' . $name . ']', $violation->message);
$this->assertNull($psr4NamespaceRule->evaluate($this->makeNode('App\\Legacy\\' . $name, $file)));
}
}

public function testRefreshesDirectoryCandidatesWhenProjectRootChanges(): void
{
$rootPath = $this->makeTemporaryDirectory('structarmed-psr4-changing-project');
mkdir($rootPath . '/shared');
$file = $rootPath . '/shared/Foo.php';
file_put_contents($file, '<?php');

$psr4NamespaceRule = new Psr4NamespaceRule('Source');

foreach (['First', 'Second'] as $project) {
$basePath = $rootPath . '/' . $project;
mkdir($basePath);
file_put_contents($basePath . '/composer.json', json_encode([
'autoload' => ['psr-4' => [$project . '\\' => '../shared/']],
]));

$psr4NamespaceRule->evaluateProject($basePath, Architecture::define());
$violation = $psr4NamespaceRule->evaluate($this->makeNode('Wrong', $file));

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

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