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
31 changes: 25 additions & 6 deletions src/LayerResolver/Resolvers/NamespaceLayerResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,16 +18,22 @@
* '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<string, string|null> */
private array $resolvedLayers = [];

/** @var array<string, list<string>> */
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
* descendant matches.
*
* @var array<string, list<string>>
*/
private array $normalisedLayers;
private readonly array $normalisedLayers;

/**
* @param array<string, string|list<string>> $layers Map of layer name → path prefixes
Expand All @@ -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;

Expand All @@ -68,15 +81,21 @@ public function resolve(string $className, string $filePath): ?string
}
}

return $matchedLayer;
return $this->resolvedLayers[$path] = $matchedLayer;
}

/**
* @return int[]|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) {
Expand All @@ -88,6 +107,6 @@ public function resolveAll(string $className, string $filePath): array
}
}

return $matched;
return $this->resolvedAllLayers[$path] = $matched;
}
}
79 changes: 79 additions & 0 deletions tests/LayerResolver/NamespaceLayerResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{bool}> */
public static function provideResolutionOrder(): iterable
{
yield 'resolve first' => [false];
yield 'resolveAll first' => [true];
}

/** @param list<string> $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<string, array{string, list<string>}> */
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(
Expand Down