diff --git a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php index 4ec15878..d6515dc6 100644 --- a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php +++ b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php @@ -7,6 +7,7 @@ use Boundwize\StructArmed\LayerResolver\LayerResolverInterface; use Boundwize\StructArmed\Util\Path; +use function array_key_exists; use function str_starts_with; use function strlen; @@ -17,8 +18,14 @@ * 'Domain' → 'src/Domain/' * A file at 'src/Domain/Entities/Order.php' resolves to 'Domain' */ -final readonly class NamespaceLayerResolver implements LayerResolverInterface +final class NamespaceLayerResolver implements LayerResolverInterface { + /** @var array */ + private array $resolvedLayers = []; + + /** @var array> */ + private array $resolvedAllLayers = []; + /** * Layer paths stored with a trailing '/' so a single str_starts_with() * against the file path (also suffixed with '/') covers both exact and @@ -26,7 +33,7 @@ * * @var array> */ - private array $normalisedLayers; + private readonly array $normalisedLayers; /** * @param array> $layers Map of layer name → path prefixes @@ -51,7 +58,13 @@ public function __construct( public function resolve(string $className, string $filePath): ?string { - $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; + $path = Path::normalise($filePath, canonicalise: true); + + if (array_key_exists($path, $this->resolvedLayers)) { + return $this->resolvedLayers[$path]; + } + + $pathWithSlash = $path . '/'; $matchedLayer = null; $matchedLength = -1; @@ -68,7 +81,7 @@ public function resolve(string $className, string $filePath): ?string } } - return $matchedLayer; + return $this->resolvedLayers[$path] = $matchedLayer; } /** @@ -76,7 +89,13 @@ public function resolve(string $className, string $filePath): ?string */ public function resolveAll(string $className, string $filePath): array { - $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; + $path = Path::normalise($filePath, canonicalise: true); + + if (isset($this->resolvedAllLayers[$path])) { + return $this->resolvedAllLayers[$path]; + } + + $pathWithSlash = $path . '/'; $matched = []; foreach ($this->normalisedLayers as $layerName => $layerPaths) { @@ -88,6 +107,6 @@ public function resolveAll(string $className, string $filePath): array } } - return $matched; + return $this->resolvedAllLayers[$path] = $matched; } } diff --git a/tests/LayerResolver/NamespaceLayerResolverTest.php b/tests/LayerResolver/NamespaceLayerResolverTest.php index 7d0b4052..cd2d1531 100644 --- a/tests/LayerResolver/NamespaceLayerResolverTest.php +++ b/tests/LayerResolver/NamespaceLayerResolverTest.php @@ -10,6 +10,7 @@ use Boundwize\StructArmed\LayerResolver\Resolvers\NamespaceLayerResolver; use Boundwize\StructArmed\Tests\ArchitectureTest; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use function dirname; @@ -173,6 +174,84 @@ public function testResolveAllReturnsEmptyForUnknownPath(): void $this->assertSame([], $layers); } + #[DataProvider('provideResolutionOrder')] + public function testRepeatedFileResolutionsIgnoreSymbolNames(bool $resolveAllFirst): void + { + $namespaceLayerResolver = new NamespaceLayerResolver( + layers: [ + 'Source' => 'src/', + 'Domain' => ['src/Domain/', 'src/Domain/Entities/'], + 'DomainAlias' => 'src/Domain/Entities/', + 'Other' => 'src/Other/', + ], + basePath: $this->basePath + ); + + foreach (['App\\Order', 'App\\OtherClass', 'App\\helper()', '{closure}', 'class@anonymous'] as $name) { + foreach ( + [ + '/src/Domain/Entities/Order.php' => ['Domain', ['Source', 'Domain', 'DomainAlias']], + '/src/Other/Service.php' => ['Other', ['Source', 'Other']], + '/src/DomainSibling/Order.php' => ['Source', ['Source']], + '/vendor/Unknown.php' => [null, []], + ] as $file => [$expectedLayer, $expectedLayers] + ) { + $filePath = $this->basePath . $file; + + if ($resolveAllFirst) { + $this->assertSame($expectedLayers, $namespaceLayerResolver->resolveAll($name, $filePath)); + $this->assertSame($expectedLayer, $namespaceLayerResolver->resolve($name, $filePath)); + } else { + $this->assertSame($expectedLayer, $namespaceLayerResolver->resolve($name, $filePath)); + $this->assertSame($expectedLayers, $namespaceLayerResolver->resolveAll($name, $filePath)); + } + } + } + } + + /** @return iterable */ + public static function provideResolutionOrder(): iterable + { + yield 'resolve first' => [false]; + yield 'resolveAll first' => [true]; + } + + /** @param list $filePaths */ + #[DataProvider('provideEquivalentFilePaths')] + public function testEquivalentFilePathsResolveIdentically(string $layerPath, array $filePaths): void + { + $resolver = new NamespaceLayerResolver(['Source' => $layerPath], $this->basePath); + $unknown = new NamespaceLayerResolver([], $this->basePath); + + foreach ($filePaths as $index => $filePath) { + $this->assertSame('Source', $resolver->resolve('Class' . $index, $filePath)); + $this->assertSame(['Source'], $resolver->resolveAll('Function' . $index, $filePath)); + $this->assertNull($unknown->resolve('Class' . $index, $filePath)); + $this->assertSame([], $unknown->resolveAll('Function' . $index, $filePath)); + } + } + + /** @return iterable}> */ + public static function provideEquivalentFilePaths(): iterable + { + yield 'canonical existing file' => [ + __DIR__, + [__FILE__, __DIR__ . '/../LayerResolver/NamespaceLayerResolverTest.php'], + ]; + yield 'Windows drive separators' => [ + 'C:\\structarmed-cache-test\\src', + ['C:\\structarmed-cache-test\\src\\Order.php', 'C:/structarmed-cache-test/src/Order.php'], + ]; + yield 'Windows UNC separators' => [ + '\\\\structarmed-cache-test\\share\\src', + ['\\\\structarmed-cache-test\\share\\src\\Order.php', '//structarmed-cache-test/share/src/Order.php'], + ]; + yield 'redundant separators' => [ + '/structarmed-cache-test/src', + ['/structarmed-cache-test/src//Order.php', '/structarmed-cache-test/src/Order.php'], + ]; + } + public function testReusesCachedMatchesForSameFilePath(): void { $chainLayerResolver = ChainLayerResolver::fromLayerConfig(