diff --git a/src/Rule/Rules/Composer/Psr4NamespaceRule.php b/src/Rule/Rules/Composer/Psr4NamespaceRule.php index aa8fa274..2409bf39 100644 --- a/src/Rule/Rules/Composer/Psr4NamespaceRule.php +++ b/src/Rule/Rules/Composer/Psr4NamespaceRule.php @@ -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; @@ -38,6 +40,9 @@ final class Psr4NamespaceRule implements RuleInterface, ProjectRuleInterface /** @var array */ private array $basePathByDirectory = []; + /** @var array> */ + private array $namespaceCandidatesByDirectory = []; + public function __construct( private readonly string $layer, private readonly Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(), @@ -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; } @@ -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 */ + 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 @@ -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; } } diff --git a/tests/Rule/Composer/Psr4NamespaceRuleTest.php b/tests/Rule/Composer/Psr4NamespaceRuleTest.php index 73893a52..afc6606d 100644 --- a/tests/Rule/Composer/Psr4NamespaceRuleTest.php +++ b/tests/Rule/Composer/Psr4NamespaceRuleTest.php @@ -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, '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, ' ['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,