Skip to content

Commit bf742d3

Browse files
committed
refactor: implement lazy plugin path resolution
1 parent 542c005 commit bf742d3

File tree

3 files changed

+127
-28
lines changed

3 files changed

+127
-28
lines changed

src/AttributeRegistry.php

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,13 @@ private static function createFromConfig(): self
8585
$scannerConfig = (array)($config['scanner'] ?? []);
8686
$cacheConfig = (array)($config['cache'] ?? []);
8787

88-
$pathResolver = new PathResolver(implode(PATH_SEPARATOR, self::resolveAllPaths()));
88+
// Create PathResolver with lazy plugin path resolution
89+
// The callback is only invoked when scanning is needed (cache miss)
90+
$pathResolver = new PathResolver(
91+
ROOT,
92+
fn(): array => (new PluginPathResolver())->getEnabledPluginPaths(),
93+
);
94+
8995
$cache = new AttributeCache(
9096
(string)($cacheConfig['config'] ?? 'default'),
9197
(bool)($cacheConfig['enabled'] ?? true),
@@ -106,30 +112,6 @@ private static function createFromConfig(): self
106112
return new self($scanner, $cache);
107113
}
108114

109-
/**
110-
* Resolve all base paths from app + all enabled plugins.
111-
*
112-
* Uses PluginPathResolver to get ALL enabled plugins (including CLI-only)
113-
* regardless of current request context. This ensures atomic discovery
114-
* where the same attributes are discovered in both CLI and web contexts.
115-
*
116-
* @return array<string> Resolved base paths
117-
*/
118-
private static function resolveAllPaths(): array
119-
{
120-
$basePaths = [];
121-
$basePaths[] = ROOT;
122-
123-
$pluginPathResolver = new PluginPathResolver();
124-
$pluginPaths = $pluginPathResolver->getEnabledPluginPaths();
125-
126-
foreach ($pluginPaths as $path) {
127-
$basePaths[] = $path;
128-
}
129-
130-
return $basePaths;
131-
}
132-
133115
/**
134116
* Discover all attributes from configured paths.
135117
*

src/Service/PathResolver.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
namespace AttributeRegistry\Service;
55

6+
use Closure;
67
use Generator;
78
use RecursiveDirectoryIterator;
89
use RecursiveIteratorIterator;
@@ -14,25 +15,52 @@ class PathResolver
1415
*/
1516
private array $basePaths;
1617

18+
private bool $pathsResolved = false;
19+
1720
/**
1821
* Constructor for PathResolver.
1922
*
20-
* @param string $basePaths Base paths separated by PATH_SEPARATOR
23+
* @param string $basePath Primary base path (typically ROOT)
24+
* @param \Closure|null $pluginPathsCallback Optional lazy callback returning additional plugin paths
2125
*/
2226
public function __construct(
23-
string $basePaths,
27+
string $basePath,
28+
private readonly ?Closure $pluginPathsCallback = null,
2429
) {
25-
$this->basePaths = array_filter(explode(PATH_SEPARATOR, $basePaths));
30+
$this->basePaths = array_filter(explode(PATH_SEPARATOR, $basePath));
31+
}
32+
33+
/**
34+
* Ensure all paths (including plugin paths) are resolved.
35+
* Only resolves once on first call for performance.
36+
*/
37+
private function ensureAllPathsResolved(): void
38+
{
39+
if ($this->pathsResolved) {
40+
return;
41+
}
42+
43+
if ($this->pluginPathsCallback instanceof \Closure) {
44+
$pluginPaths = ($this->pluginPathsCallback)();
45+
foreach ($pluginPaths as $path) {
46+
$this->basePaths[] = $path;
47+
}
48+
}
49+
50+
$this->pathsResolved = true;
2651
}
2752

2853
/**
2954
* Resolve all paths from glob patterns.
55+
* Lazily resolves plugin paths on first invocation.
3056
*
3157
* @param array<string> $globPatterns Array of glob patterns
3258
* @return \Generator<string> Generator yielding file paths
3359
*/
3460
public function resolveAllPaths(array $globPatterns): Generator
3561
{
62+
$this->ensureAllPathsResolved();
63+
3664
foreach ($this->basePaths as $basePath) {
3765
foreach ($this->resolvePatternsForPath($basePath, $globPatterns) as $path) {
3866
yield $path;

tests/TestCase/Service/PathResolverTest.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,95 @@ public function testResolveMultipleBasePaths(): void
9797
rmdir($pluginPath);
9898
}
9999

100+
public function testLazyPluginPathCallbackIsNotInvokedOnConstruction(): void
101+
{
102+
$callbackInvoked = false;
103+
$callback = function () use (&$callbackInvoked): array {
104+
$callbackInvoked = true;
105+
106+
return ['/some/plugin/path'];
107+
};
108+
109+
new PathResolver($this->testAppPath, $callback);
110+
111+
// Callback should NOT be invoked during construction
112+
$this->assertFalse($callbackInvoked, 'Plugin path callback should not be invoked during construction');
113+
}
114+
115+
public function testLazyPluginPathCallbackIsInvokedOnFirstResolve(): void
116+
{
117+
$callbackInvoked = false;
118+
$callback = function () use (&$callbackInvoked): array {
119+
$callbackInvoked = true;
120+
121+
return [];
122+
};
123+
124+
$resolver = new PathResolver($this->testAppPath, $callback);
125+
126+
// Invoke resolveAllPaths to trigger callback
127+
iterator_to_array($resolver->resolveAllPaths(['src/*.php']));
128+
129+
// Callback should be invoked on first resolve
130+
$this->assertTrue($callbackInvoked, 'Plugin path callback should be invoked on first path resolution');
131+
}
132+
133+
public function testLazyPluginPathCallbackIsOnlyInvokedOnce(): void
134+
{
135+
$callbackInvokeCount = 0;
136+
$callback = function () use (&$callbackInvokeCount): array {
137+
$callbackInvokeCount++;
138+
139+
return [];
140+
};
141+
142+
$resolver = new PathResolver($this->testAppPath, $callback);
143+
144+
// Invoke resolveAllPaths multiple times
145+
iterator_to_array($resolver->resolveAllPaths(['src/*.php']));
146+
iterator_to_array($resolver->resolveAllPaths(['src/*.php']));
147+
iterator_to_array($resolver->resolveAllPaths(['src/*.php']));
148+
149+
// Callback should only be invoked once
150+
$this->assertSame(1, $callbackInvokeCount, 'Plugin path callback should only be invoked once');
151+
}
152+
153+
public function testLazyPluginPathsAreMergedWithBasePath(): void
154+
{
155+
// Create a plugin path
156+
$pluginPath = sys_get_temp_dir() . '/attribute_registry_lazy_plugin_' . uniqid();
157+
mkdir($pluginPath . '/src', 0755, true);
158+
file_put_contents($pluginPath . '/src/PluginClass.php', "<?php\n// Plugin file");
159+
160+
$callback = fn(): array => [$pluginPath];
161+
162+
$resolver = new PathResolver($this->testAppPath, $callback);
163+
164+
$patterns = ['src/*.php'];
165+
$paths = iterator_to_array($resolver->resolveAllPaths($patterns));
166+
167+
// Should find files from both base path and lazily resolved plugin paths
168+
$this->assertContains($this->testAppPath . '/src/TestClass.php', $paths);
169+
$this->assertContains($pluginPath . '/src/PluginClass.php', $paths);
170+
171+
// Cleanup plugin path
172+
unlink($pluginPath . '/src/PluginClass.php');
173+
rmdir($pluginPath . '/src');
174+
rmdir($pluginPath);
175+
}
176+
177+
public function testPathResolverWorksWithoutLazyCallback(): void
178+
{
179+
// Ensure backward compatibility - can be constructed without callback
180+
$resolver = new PathResolver($this->testAppPath);
181+
182+
$patterns = ['src/*.php'];
183+
$paths = iterator_to_array($resolver->resolveAllPaths($patterns));
184+
185+
$this->assertContains($this->testAppPath . '/src/TestClass.php', $paths);
186+
$this->assertNotEmpty($paths);
187+
}
188+
100189
private function createTestStructure(): void
101190
{
102191
$structure = [

0 commit comments

Comments
 (0)