From 198620616fd44bf11be2ccee8930d58da2e9b399 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 6 May 2026 13:20:03 +0100 Subject: [PATCH 1/5] Add package autodiscovery --- src/Application.php | 26 +++ src/Autodiscovery/AutodiscoveredPackages.php | 75 ++++++++ src/Autodiscovery/DiscoveryRunner.php | 23 +++ src/Autodiscovery/ManifestCache.php | 44 +++++ src/Autodiscovery/PackageManifest.php | 72 +++++++ src/Bootstrappers/RegisterAliases.php | 9 +- src/Bootstrappers/RegisterProviders.php | 12 +- src/ComposerHooks.php | 23 +++ tests/Unit/ApplicationTest.php | 10 + .../AutodiscoveredPackagesTest.php | 176 ++++++++++++++++++ .../Autodiscovery/DiscoveryRunnerTest.php | 38 ++++ .../Unit/Autodiscovery/ManifestCacheTest.php | 91 +++++++++ .../Autodiscovery/PackageManifestTest.php | 158 ++++++++++++++++ .../Bootstrappers/RegisterAliasesTest.php | 38 +++- .../Bootstrappers/RegisterProvidersTest.php | 29 ++- 15 files changed, 817 insertions(+), 7 deletions(-) create mode 100644 src/Autodiscovery/AutodiscoveredPackages.php create mode 100644 src/Autodiscovery/DiscoveryRunner.php create mode 100644 src/Autodiscovery/ManifestCache.php create mode 100644 src/Autodiscovery/PackageManifest.php create mode 100644 src/ComposerHooks.php create mode 100644 tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php create mode 100644 tests/Unit/Autodiscovery/DiscoveryRunnerTest.php create mode 100644 tests/Unit/Autodiscovery/ManifestCacheTest.php create mode 100644 tests/Unit/Autodiscovery/PackageManifestTest.php diff --git a/src/Application.php b/src/Application.php index 0f41a494..c8f251de 100644 --- a/src/Application.php +++ b/src/Application.php @@ -8,6 +8,10 @@ use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Rareloop\Lumberjack\Http\ResponseEmitter; +use Symfony\Component\Filesystem\Filesystem; +use Rareloop\Lumberjack\Autodiscovery\AutodiscoveredPackages; +use Rareloop\Lumberjack\Autodiscovery\ManifestCache; +use Rareloop\Lumberjack\Autodiscovery\PackageManifest; class Application implements ContainerInterface { @@ -44,6 +48,18 @@ protected function bindPathsInContainer() { $this->bind('path.base', $this->basePath()); $this->bind('path.config', $this->configPath()); + $this->bind('path.bootstrap', $this->bootstrapPath()); + $this->bind('path.vendor', $this->vendorPath()); + + $this->singleton(ManifestCache::class, \DI\autowire() + ->constructorParameter('cachePath', $this->bootstrapPath('cache' . DIRECTORY_SEPARATOR . 'packages.php'))); + + $this->singleton(PackageManifest::class, \DI\autowire() + ->constructorParameter('basePath', \DI\get('path.base')) + ->constructorParameter('vendorPath', \DI\get('path.vendor'))); + + $this->singleton(AutodiscoveredPackages::class, \DI\autowire() + ->constructorParameter('debug', \DI\factory(fn() => defined('WP_DEBUG') && WP_DEBUG))); } public function basePath() @@ -56,6 +72,16 @@ public function configPath() return $this->basePath . DIRECTORY_SEPARATOR . 'config'; } + public function vendorPath() + { + return $this->basePath . DIRECTORY_SEPARATOR . 'vendor'; + } + + public function bootstrapPath(string $path = '') + { + return $this->basePath . DIRECTORY_SEPARATOR . 'bootstrap' . ($path ? DIRECTORY_SEPARATOR . $path : ''); + } + public function bind($key, $value) { // Prevent PHP-DI from creating singletons from class binds or closure factories diff --git a/src/Autodiscovery/AutodiscoveredPackages.php b/src/Autodiscovery/AutodiscoveredPackages.php new file mode 100644 index 00000000..306674ea --- /dev/null +++ b/src/Autodiscovery/AutodiscoveredPackages.php @@ -0,0 +1,75 @@ +getManifest(), 'providers', []); + } + + public function aliases(): array + { + return Arr::get($this->getManifest(), 'aliases', []); + } + + protected function getManifest(): array + { + if ($this->manifest !== null) { + return $this->manifest; + } + + if (!$this->isStale() && $this->cache->exists()) { + return $this->manifest = $this->cache->read(); + } + + return $this->manifest = $this->refresh(); + } + + protected function isStale(): bool + { + if (!$this->cache->exists()) { + return true; + } + + return $this->builder->mtime() > $this->cache->mtime(); + } + + public function refresh(): array + { + $manifest = $this->builder->build(); + + try { + $this->cache->write($manifest); + } catch (IOExceptionInterface $e) { + $this->warn("The {$this->cache->getPath()} directory is not writable. Please check your permissions."); + } + + return $manifest; + } + + protected function warn(string $message): void + { + if ($this->debug) { + if ($this->app->has(LoggerInterface::class)) { + $this->app->get(LoggerInterface::class)->warning($message); + } + } + } +} diff --git a/src/Autodiscovery/DiscoveryRunner.php b/src/Autodiscovery/DiscoveryRunner.php new file mode 100644 index 00000000..39b26dc8 --- /dev/null +++ b/src/Autodiscovery/DiscoveryRunner.php @@ -0,0 +1,23 @@ +bootstrap($app); + + $app->get(AutodiscoveredPackages::class)->refresh(); + } +} diff --git a/src/Autodiscovery/ManifestCache.php b/src/Autodiscovery/ManifestCache.php new file mode 100644 index 00000000..e6d2aedb --- /dev/null +++ b/src/Autodiscovery/ManifestCache.php @@ -0,0 +1,44 @@ +filesystem->exists($this->cachePath); + } + + public function read(): array + { + $data = require $this->cachePath; + + return is_array($data) ? $data : []; + } + + public function write(array $manifest): void + { + $this->filesystem->dumpFile( + $this->cachePath, + 'exists() ? filemtime($this->cachePath) : 0; + } + + public function getPath(): string + { + return $this->cachePath; + } +} diff --git a/src/Autodiscovery/PackageManifest.php b/src/Autodiscovery/PackageManifest.php new file mode 100644 index 00000000..88ebb282 --- /dev/null +++ b/src/Autodiscovery/PackageManifest.php @@ -0,0 +1,72 @@ +getInstalledPackages(); + $ignore = $this->getPackagesToIgnore(); + + return Collection::make($packages) + ->mapWithKeys(fn ($package) => [Arr::get($package, 'name') => Arr::get($package, 'extra.lumberjack', [])]) + ->reject(fn ($extra, $name) => $this->shouldIgnore($name, $ignore)) + ->filter() + ->reduce(function ($carry, $extra) { + return [ + 'providers' => array_merge(Arr::get($carry, 'providers', []), Arr::get($extra, 'providers', [])), + 'aliases' => array_merge(Arr::get($carry, 'aliases', []), Arr::get($extra, 'aliases', [])), + ]; + }, ['providers' => [], 'aliases' => []]); + } + + public function mtime(): int + { + $path = $this->vendorPath . DIRECTORY_SEPARATOR . 'composer' . DIRECTORY_SEPARATOR . 'installed.json'; + + return $this->filesystem->exists($path) ? filemtime($path) : 0; + } + + protected function getInstalledPackages(): array + { + $path = $this->vendorPath . DIRECTORY_SEPARATOR . 'composer' . DIRECTORY_SEPARATOR . 'installed.json'; + + if (!$this->filesystem->exists($path)) { + return []; + } + + $installed = json_decode(file_get_contents($path), true); + + return Arr::get($installed, 'packages', $installed); + } + + protected function getPackagesToIgnore(): array + { + $path = $this->basePath . DIRECTORY_SEPARATOR . 'composer.json'; + + if (!$this->filesystem->exists($path)) { + return []; + } + + $composer = json_decode(file_get_contents($path), true); + + return Arr::get($composer, 'extra.lumberjack.dont-discover', []); + } + + protected function shouldIgnore(string $name, array $ignore): bool + { + return in_array($name, $ignore) || in_array('*', $ignore); + } +} diff --git a/src/Bootstrappers/RegisterAliases.php b/src/Bootstrappers/RegisterAliases.php index ae1eebae..6daa435f 100644 --- a/src/Bootstrappers/RegisterAliases.php +++ b/src/Bootstrappers/RegisterAliases.php @@ -3,14 +3,21 @@ namespace Rareloop\Lumberjack\Bootstrappers; use Rareloop\Lumberjack\Application; +use Rareloop\Lumberjack\Autodiscovery\AutodiscoveredPackages; class RegisterAliases { public function bootstrap(Application $app) { $config = $app->get('config'); + $manifest = $app->get(AutodiscoveredPackages::class); - foreach ($config->get('app.aliases', []) as $alias => $realClassname) { + $aliases = array_merge( + $manifest->aliases(), + $config->get('app.aliases', []) + ); + + foreach ($aliases as $alias => $realClassname) { class_alias($realClassname, $alias); } } diff --git a/src/Bootstrappers/RegisterProviders.php b/src/Bootstrappers/RegisterProviders.php index b4d4c65b..41c5da1f 100644 --- a/src/Bootstrappers/RegisterProviders.php +++ b/src/Bootstrappers/RegisterProviders.php @@ -3,8 +3,10 @@ namespace Rareloop\Lumberjack\Bootstrappers; use Rareloop\Lumberjack\Application; +use Rareloop\Lumberjack\Autodiscovery\AutodiscoveredPackages; use Rareloop\Lumberjack\Providers\LogServiceProvider; use Rareloop\Lumberjack\Providers\IgnitionServiceProvider; +use Illuminate\Support\Collection; class RegisterProviders { @@ -14,7 +16,15 @@ public function bootstrap(Application $app) $this->registerBaseProviders($app); - $providers = $config->get('app.providers', []); + $manifest = $app->get(AutodiscoveredPackages::class); + + $providers = Collection::make($manifest->providers()) + ->concat($config->get('app.providers', [])) + ->mapWithKeys(function ($provider) { + return [ + (is_string($provider) ? $provider : $provider::class) => $provider, + ]; + }); foreach ($providers as $provider) { $app->register($provider); diff --git a/src/ComposerHooks.php b/src/ComposerHooks.php new file mode 100644 index 00000000..ab61bbb6 --- /dev/null +++ b/src/ComposerHooks.php @@ -0,0 +1,23 @@ +getComposer()->getConfig()->get('vendor-dir'); + $basePath = dirname($vendorPath); + + (new DiscoveryRunner())->run(new Application($basePath)); + } +} diff --git a/tests/Unit/ApplicationTest.php b/tests/Unit/ApplicationTest.php index c15c04db..3b4c3e6a 100644 --- a/tests/Unit/ApplicationTest.php +++ b/tests/Unit/ApplicationTest.php @@ -48,6 +48,16 @@ public function config_path_is_set_in_container_when_basepath_passed_to_construc $this->assertSame('/base/path/config', $app->get('path.config')); } + /** @test */ + public function bootstrap_path_is_set_in_container_when_basepath_passed_to_constructor() + { + $app = new Application('/base/path'); + + $this->assertSame('/base/path/bootstrap', $app->bootstrapPath()); + $this->assertSame('/base/path/bootstrap', $app->get('path.bootstrap')); + $this->assertSame('/base/path/bootstrap/cache/packages.php', $app->bootstrapPath('cache/packages.php')); + } + /** @test */ public function can_bind_a_value() { diff --git a/tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php b/tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php new file mode 100644 index 00000000..b93d0498 --- /dev/null +++ b/tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php @@ -0,0 +1,176 @@ +builder = Mockery::mock(PackageManifest::class); + $this->cache = Mockery::mock(ManifestCache::class); + $this->app = Mockery::mock(Application::class); + } + + /** @test */ + public function it_reads_from_cache_if_not_stale() + { + $manifestData = ['providers' => ['Cached\Provider']]; + + $this->cache->shouldReceive('exists')->andReturn(true); + $this->builder->shouldReceive('mtime')->andReturn(100); + $this->cache->shouldReceive('mtime')->andReturn(200); + $this->cache->shouldReceive('read')->once()->andReturn($manifestData); + + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + + $this->assertEquals(['Cached\Provider'], $orchestrator->providers()); + } + + /** @test */ + public function it_can_get_aliases() + { + $manifestData = ['aliases' => ['Foo' => 'Bar']]; + + $this->cache->shouldReceive('exists')->andReturn(true); + $this->builder->shouldReceive('mtime')->andReturn(100); + $this->cache->shouldReceive('mtime')->andReturn(200); + $this->cache->shouldReceive('read')->once()->andReturn($manifestData); + + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + + $this->assertEquals(['Foo' => 'Bar'], $orchestrator->aliases()); + } + + /** @test */ + public function it_refreshes_cache_if_stale() + { + $manifestData = ['providers' => ['Fresh\Provider']]; + + $this->cache->shouldReceive('exists')->andReturn(true); + $this->builder->shouldReceive('mtime')->andReturn(300); + $this->cache->shouldReceive('mtime')->andReturn(200); + + $this->builder->shouldReceive('build')->once()->andReturn($manifestData); + $this->cache->shouldReceive('write')->once()->with($manifestData); + + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + + $this->assertEquals(['Fresh\Provider'], $orchestrator->providers()); + } + + /** @test */ + public function it_refreshes_cache_if_cache_missing() + { + $manifestData = ['providers' => ['Fresh\Provider']]; + + $this->cache->shouldReceive('exists')->andReturn(false); + $this->builder->shouldReceive('build')->once()->andReturn($manifestData); + $this->cache->shouldReceive('write')->once()->with($manifestData); + + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + + $this->assertEquals(['Fresh\Provider'], $orchestrator->providers()); + } + + /** @test */ + public function it_logs_a_warning_if_cache_is_unwritable_and_debug_is_enabled() + { + $manifestData = ['providers' => ['Fresh\Provider']]; + + $this->cache->shouldReceive('exists')->andReturn(false); + $this->builder->shouldReceive('build')->andReturn($manifestData); + $this->cache->shouldReceive('getPath')->andReturn('/path/to/cache'); + + // Simulate write failure + $this->cache->shouldReceive('write')->andThrow(new \Symfony\Component\Filesystem\Exception\IOException('Unwritable')); + + $logger = Mockery::mock(\Psr\Log\LoggerInterface::class); + $logger->shouldReceive('warning')->once()->with("The /path/to/cache directory is not writable. Please check your permissions."); + + $this->app->shouldReceive('has')->with(\Psr\Log\LoggerInterface::class)->andReturn(true); + $this->app->shouldReceive('get')->with(\Psr\Log\LoggerInterface::class)->andReturn($logger); + + // Explicitly set debug to true + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, true); + $orchestrator->refresh(); + } + + /** @test */ + public function it_does_not_log_a_warning_if_debug_is_disabled() + { + $manifestData = ['providers' => ['Fresh\Provider']]; + + $this->cache->shouldReceive('exists')->andReturn(false); + $this->builder->shouldReceive('build')->andReturn($manifestData); + $this->cache->shouldReceive('getPath')->andReturn('/path/to/cache'); + + // Simulate write failure + $this->cache->shouldReceive('write')->andThrow(new \Symfony\Component\Filesystem\Exception\IOException('Unwritable')); + + $this->app->shouldNotReceive('has'); + $this->app->shouldNotReceive('get'); + + // Explicitly set debug to false + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, false); + $orchestrator->refresh(); + } + + /** @test */ + public function it_does_not_log_if_logger_is_missing() + { + $manifestData = ['providers' => ['Fresh\Provider']]; + + $this->cache->shouldReceive('exists')->andReturn(false); + $this->builder->shouldReceive('build')->andReturn($manifestData); + $this->cache->shouldReceive('getPath')->andReturn('/path/to/cache'); + + // Simulate write failure + $this->cache->shouldReceive('write')->andThrow(new \Symfony\Component\Filesystem\Exception\IOException('Unwritable')); + + $this->app->shouldReceive('has')->with(\Psr\Log\LoggerInterface::class)->andReturn(false); + $this->app->shouldNotReceive('get'); + + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, true); + $orchestrator->refresh(); + } + + /** @test */ + public function manifest_is_only_loaded_once() + { + $manifestData = ['providers' => []]; + + $this->cache->shouldReceive('exists')->andReturn(true); + $this->builder->shouldReceive('mtime')->andReturn(100); + $this->cache->shouldReceive('mtime')->andReturn(200); + $this->cache->shouldReceive('read')->once()->andReturn($manifestData); + + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + + $orchestrator->providers(); + $orchestrator->providers(); // Second call should not trigger 'read' + + // Asserting equality to trigger PHPUnit count + $this->assertEquals([], $orchestrator->providers()); + } + + /** @test */ + public function debug_flag_is_accessible() + { + $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, true); + $this->assertTrue($orchestrator->debug); + } +} diff --git a/tests/Unit/Autodiscovery/DiscoveryRunnerTest.php b/tests/Unit/Autodiscovery/DiscoveryRunnerTest.php new file mode 100644 index 00000000..87a10119 --- /dev/null +++ b/tests/Unit/Autodiscovery/DiscoveryRunnerTest.php @@ -0,0 +1,38 @@ + [], + ]); + + $app = Mockery::mock(Application::class); + $app->shouldReceive('configPath')->andReturn($root->url() . '/config'); + + // LoadConfiguration expects these bindings to be possible + $app->shouldReceive('bind')->with('config', Mockery::type(Config::class)); + $app->shouldReceive('bind')->with(Config::class, Mockery::type(Config::class)); + + $orchestrator = Mockery::mock(AutodiscoveredPackages::class); + $orchestrator->shouldReceive('refresh')->once(); + + $app->shouldReceive('get')->with(AutodiscoveredPackages::class)->andReturn($orchestrator); + + (new DiscoveryRunner())->run($app); + } +} diff --git a/tests/Unit/Autodiscovery/ManifestCacheTest.php b/tests/Unit/Autodiscovery/ManifestCacheTest.php new file mode 100644 index 00000000..baca5b6f --- /dev/null +++ b/tests/Unit/Autodiscovery/ManifestCacheTest.php @@ -0,0 +1,91 @@ +root = vfsStream::setup('root'); + $this->filesystem = new Filesystem(); + } + + /** @test */ + public function it_can_check_if_cache_exists() + { + $cachePath = $this->root->url() . '/packages.php'; + $cache = new ManifestCache($this->filesystem, $cachePath); + + $this->assertFalse($cache->exists()); + + file_put_contents($cachePath, 'assertTrue($cache->exists()); + } + + /** @test */ + public function it_can_write_the_cache() + { + $cachePath = $this->root->url() . '/packages.php'; + $cache = new ManifestCache($this->filesystem, $cachePath); + + $manifest = ['providers' => ['Foo\Bar']]; + $cache->write($manifest); + + $this->assertTrue($this->root->hasChild('packages.php')); + $data = require $cachePath; + $this->assertEquals($manifest, $data); + } + + /** @test */ + public function it_can_read_the_cache() + { + $cachePath = $this->root->url() . '/packages.php'; + $cache = new ManifestCache($this->filesystem, $cachePath); + + $manifest = ['providers' => ['Foo\Bar']]; + file_put_contents($cachePath, 'assertEquals($manifest, $cache->read()); + } + + /** @test */ + public function it_returns_empty_array_if_cache_is_malformed() + { + $cachePath = $this->root->url() . '/packages.php'; + $cache = new ManifestCache($this->filesystem, $cachePath); + + file_put_contents($cachePath, 'assertEquals([], $cache->read()); + } + + /** @test */ + public function it_can_get_the_mtime() + { + $cachePath = $this->root->url() . '/packages.php'; + $cache = new ManifestCache($this->filesystem, $cachePath); + + $this->assertEquals(0, $cache->mtime()); + + file_put_contents($cachePath, 'assertGreaterThan(0, $cache->mtime()); + } + + /** @test */ + public function it_can_get_the_path() + { + $cachePath = '/path/to/cache.php'; + $cache = new ManifestCache($this->filesystem, $cachePath); + + $this->assertEquals($cachePath, $cache->getPath()); + } +} diff --git a/tests/Unit/Autodiscovery/PackageManifestTest.php b/tests/Unit/Autodiscovery/PackageManifestTest.php new file mode 100644 index 00000000..7cedb93c --- /dev/null +++ b/tests/Unit/Autodiscovery/PackageManifestTest.php @@ -0,0 +1,158 @@ +root = vfsStream::setup('root', null, [ + 'vendor' => [ + 'composer' => [ + 'installed.json' => json_encode([ + 'packages' => [ + [ + 'name' => 'package/one', + 'extra' => [ + 'lumberjack' => [ + 'providers' => ['Package\One\ServiceProvider'], + 'aliases' => ['One' => 'Package\One\Facade'], + ], + ], + ], + ], + ]), + ], + ], + 'composer.json' => json_encode([]), + ]); + + $this->filesystem = new Filesystem(); + } + + /** @test */ + public function it_can_build_the_manifest() + { + $manifest = new PackageManifest( + $this->filesystem, + $this->root->url(), + $this->root->url() . '/vendor' + ); + + $data = $manifest->build(); + + $this->assertEquals(['Package\One\ServiceProvider'], $data['providers']); + $this->assertEquals(['One' => 'Package\One\Facade'], $data['aliases']); + } + + /** @test */ + public function it_respects_dont_discover() + { + $this->root->getChild('composer.json')->setContent(json_encode([ + 'extra' => [ + 'lumberjack' => [ + 'dont-discover' => ['package/one'], + ], + ], + ])); + + $manifest = new PackageManifest( + $this->filesystem, + $this->root->url(), + $this->root->url() . '/vendor' + ); + + $data = $manifest->build(); + + $this->assertEmpty($data['providers']); + $this->assertEmpty($data['aliases']); + } + + /** @test */ + public function it_respects_wildcard_dont_discover() + { + $this->root->getChild('composer.json')->setContent(json_encode([ + 'extra' => [ + 'lumberjack' => [ + 'dont-discover' => ['*'], + ], + ], + ])); + + $manifest = new PackageManifest( + $this->filesystem, + $this->root->url(), + $this->root->url() . '/vendor' + ); + + $data = $manifest->build(); + + $this->assertEmpty($data['providers']); + } + + /** @test */ + public function it_can_get_the_mtime_of_installed_json() + { + $manifest = new PackageManifest( + $this->filesystem, + $this->root->url(), + $this->root->url() . '/vendor' + ); + + $this->assertGreaterThan(0, $manifest->mtime()); + } + + /** @test */ + public function it_returns_zero_mtime_if_installed_json_missing() + { + $this->root->getChild('vendor/composer')->removeChild('installed.json'); + + $manifest = new PackageManifest( + $this->filesystem, + $this->root->url(), + $this->root->url() . '/vendor' + ); + + $this->assertEquals(0, $manifest->mtime()); + } + + /** @test */ + public function it_returns_empty_packages_if_installed_json_missing() + { + $this->root->getChild('vendor/composer')->removeChild('installed.json'); + + $manifest = new PackageManifest( + $this->filesystem, + $this->root->url(), + $this->root->url() . '/vendor' + ); + + $data = $manifest->build(); + $this->assertEmpty($data['providers']); + } + + /** @test */ + public function it_returns_empty_ignore_list_if_composer_json_missing() + { + $this->root->removeChild('composer.json'); + + $manifest = new PackageManifest( + $this->filesystem, + $this->root->url(), + $this->root->url() . '/vendor' + ); + + $data = $manifest->build(); + $this->assertEquals(['Package\One\ServiceProvider'], $data['providers']); + } +} diff --git a/tests/Unit/Bootstrappers/RegisterAliasesTest.php b/tests/Unit/Bootstrappers/RegisterAliasesTest.php index e4a28392..94458fcf 100644 --- a/tests/Unit/Bootstrappers/RegisterAliasesTest.php +++ b/tests/Unit/Bootstrappers/RegisterAliasesTest.php @@ -7,24 +7,56 @@ use Rareloop\Lumberjack\Application; use Rareloop\Lumberjack\Bootstrappers\RegisterAliases; use Rareloop\Lumberjack\Config; +use Rareloop\Lumberjack\Autodiscovery\AutodiscoveredPackages; class RegisterAliasesTest extends TestCase { + use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; + /** @test */ public function calls_class_alias_on_all_alias_mappings() { $app = new Application; + + $manifest = Mockery::mock(AutodiscoveredPackages::class); + $manifest->shouldReceive('aliases')->andReturn([]); + $app->bind(AutodiscoveredPackages::class, $manifest); + + $config = new Config; + $config->set('app.aliases', [ + 'FooOne' => TestClassToAlias::class, + ]); + $app->bind('config', $config); + + $bootstrapper = new RegisterAliases; + $bootstrapper->bootstrap($app); + + $this->assertTrue(class_exists('FooOne')); + $this->assertInstanceOf(TestClassToAlias::class, new \FooOne); + } + + /** @test */ + public function user_defined_aliases_take_precedence_over_autodiscovered_ones() + { + $app = new Application; + + $manifest = Mockery::mock(AutodiscoveredPackages::class); + $manifest->shouldReceive('aliases')->andReturn([ + 'FooTwo' => 'Package\Class\Foo', + ]); + $app->bind(AutodiscoveredPackages::class, $manifest); + $config = new Config; $config->set('app.aliases', [ - 'Foo' => TestClassToAlias::class, + 'FooTwo' => TestClassToAlias::class, ]); $app->bind('config', $config); $bootstrapper = new RegisterAliases; $bootstrapper->bootstrap($app); - $this->assertTrue(class_exists('Foo')); - $this->assertInstanceOf(TestClassToAlias::class, new \Foo); + $this->assertTrue(class_exists('FooTwo')); + $this->assertInstanceOf(TestClassToAlias::class, new \FooTwo); } } diff --git a/tests/Unit/Bootstrappers/RegisterProvidersTest.php b/tests/Unit/Bootstrappers/RegisterProvidersTest.php index 912a14f3..749f8ff7 100644 --- a/tests/Unit/Bootstrappers/RegisterProvidersTest.php +++ b/tests/Unit/Bootstrappers/RegisterProvidersTest.php @@ -8,6 +8,7 @@ use Rareloop\Lumberjack\Bootstrappers\LoadConfiguration; use Rareloop\Lumberjack\Bootstrappers\RegisterProviders; use Rareloop\Lumberjack\Config; +use Rareloop\Lumberjack\Autodiscovery\AutodiscoveredPackages; use Rareloop\Lumberjack\Providers\ServiceProvider; class RegisterProvidersTest extends TestCase @@ -19,6 +20,10 @@ public function registers_all_providers_found_in_config() { $app = new Application; + $manifest = Mockery::mock(AutodiscoveredPackages::class); + $manifest->shouldReceive('providers')->andReturn([]); + $app->bind(AutodiscoveredPackages::class, $manifest); + $provider1 = Mockery::mock(RPTestServiceProvider1::class, [$app]); $provider1->shouldReceive('register')->once(); $provider2 = Mockery::mock(RPTestServiceProvider2::class, [$app]); @@ -36,22 +41,42 @@ public function registers_all_providers_found_in_config() } /** @test */ - public function should_not_fall_over_on_empty_config_data() + public function user_provided_instance_takes_precedence_over_autodiscovered_class_string() { $app = new Application; + // The user's specific instance they want to use + $userInstance = new RPTestServiceProvider1($app); + $userInstance->foo = 'bar'; + + // Autodiscovery finds the class name + $manifest = Mockery::mock(AutodiscoveredPackages::class); + $manifest->shouldReceive('providers')->andReturn([ + RPTestServiceProvider1::class, + ]); + $app->bind(AutodiscoveredPackages::class, $manifest); + + // User configures the specific instance $config = new Config; + $config->set('app.providers', [ + $userInstance, + ]); $app->bind('config', $config); $registerProvidersBootstrapper = new RegisterProviders; $registerProvidersBootstrapper->bootstrap($app); - $this->addToAssertionCount(1); // does not throw an exception + // Verify that the instance registered in the app is the one the user provided + $registeredProvider = $app->getProvider(RPTestServiceProvider1::class); + $this->assertSame($userInstance, $registeredProvider); + $this->assertEquals('bar', $registeredProvider->foo); } } class RPTestServiceProvider1 extends ServiceProvider { + public $foo; + public function register() {} } From eb4914ec74970552a08eaadf0b3df376d46a3736 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Thu, 7 May 2026 08:18:13 +0100 Subject: [PATCH 2/5] Add package autodiscovery system --- src/Application.php | 27 +++- src/Autodiscovery/AutodiscoveredPackages.php | 52 +------ src/Autodiscovery/DiscoveryRunner.php | 60 ++++++-- src/Autodiscovery/PackageManifest.php | 83 +++++++++-- src/ComposerHooks.php | 10 +- .../AutodiscoveredPackagesTest.php | 130 ++++-------------- .../Autodiscovery/DiscoveryRunnerTest.php | 106 +++++++++++--- .../Autodiscovery/PackageManifestTest.php | 118 ++-------------- 8 files changed, 279 insertions(+), 307 deletions(-) diff --git a/src/Application.php b/src/Application.php index c8f251de..2168a968 100644 --- a/src/Application.php +++ b/src/Application.php @@ -19,6 +19,7 @@ class Application implements ContainerInterface private $loadedProviders = []; private $booted = false; private $basePath; + private $vendorPath; private $requestHandled = false; private $nonSingletonClassBinds = []; @@ -41,7 +42,21 @@ public function setBasePath(string $basePath) { $this->basePath = $basePath; + $this->bootstrapContainer(); + } + + public function useVendorPath(string $path) + { + $this->vendorPath = $path; + + $this->bootstrapContainer(); + } + + protected function bootstrapContainer() + { $this->bindPathsInContainer(); + + $this->registerAutodiscoveryBindings(); } protected function bindPathsInContainer() @@ -50,16 +65,14 @@ protected function bindPathsInContainer() $this->bind('path.config', $this->configPath()); $this->bind('path.bootstrap', $this->bootstrapPath()); $this->bind('path.vendor', $this->vendorPath()); + } + protected function registerAutodiscoveryBindings() + { $this->singleton(ManifestCache::class, \DI\autowire() ->constructorParameter('cachePath', $this->bootstrapPath('cache' . DIRECTORY_SEPARATOR . 'packages.php'))); - $this->singleton(PackageManifest::class, \DI\autowire() - ->constructorParameter('basePath', \DI\get('path.base')) - ->constructorParameter('vendorPath', \DI\get('path.vendor'))); - - $this->singleton(AutodiscoveredPackages::class, \DI\autowire() - ->constructorParameter('debug', \DI\factory(fn() => defined('WP_DEBUG') && WP_DEBUG))); + $this->singleton(AutodiscoveredPackages::class, \DI\autowire()); } public function basePath() @@ -74,7 +87,7 @@ public function configPath() public function vendorPath() { - return $this->basePath . DIRECTORY_SEPARATOR . 'vendor'; + return $this->vendorPath ?: $this->basePath . DIRECTORY_SEPARATOR . 'vendor'; } public function bootstrapPath(string $path = '') diff --git a/src/Autodiscovery/AutodiscoveredPackages.php b/src/Autodiscovery/AutodiscoveredPackages.php index 306674ea..e0bbf061 100644 --- a/src/Autodiscovery/AutodiscoveredPackages.php +++ b/src/Autodiscovery/AutodiscoveredPackages.php @@ -3,30 +3,23 @@ namespace Rareloop\Lumberjack\Autodiscovery; use Illuminate\Support\Arr; -use Psr\Log\LoggerInterface; -use Rareloop\Lumberjack\Application; -use Symfony\Component\Filesystem\Exception\IOExceptionInterface; class AutodiscoveredPackages { protected ?array $manifest = null; - public function __construct( - protected PackageManifest $builder, - protected ManifestCache $cache, - protected Application $app, - public readonly bool $debug = false - ) { + public function __construct(protected ManifestCache $cache) + { } public function providers(): array { - return Arr::get($this->getManifest(), 'providers', []); + return (array) Arr::get($this->getManifest(), 'providers', []); } public function aliases(): array { - return Arr::get($this->getManifest(), 'aliases', []); + return (array) Arr::get($this->getManifest(), 'aliases', []); } protected function getManifest(): array @@ -35,41 +28,10 @@ protected function getManifest(): array return $this->manifest; } - if (!$this->isStale() && $this->cache->exists()) { - return $this->manifest = $this->cache->read(); - } - - return $this->manifest = $this->refresh(); - } - - protected function isStale(): bool - { - if (!$this->cache->exists()) { - return true; - } - - return $this->builder->mtime() > $this->cache->mtime(); - } - - public function refresh(): array - { - $manifest = $this->builder->build(); - - try { - $this->cache->write($manifest); - } catch (IOExceptionInterface $e) { - $this->warn("The {$this->cache->getPath()} directory is not writable. Please check your permissions."); + if ($this->cache->exists()) { + return $this->manifest = (array) $this->cache->read(); } - return $manifest; - } - - protected function warn(string $message): void - { - if ($this->debug) { - if ($this->app->has(LoggerInterface::class)) { - $this->app->get(LoggerInterface::class)->warning($message); - } - } + return $this->manifest = ['providers' => [], 'aliases' => []]; } } diff --git a/src/Autodiscovery/DiscoveryRunner.php b/src/Autodiscovery/DiscoveryRunner.php index 39b26dc8..030d275d 100644 --- a/src/Autodiscovery/DiscoveryRunner.php +++ b/src/Autodiscovery/DiscoveryRunner.php @@ -2,22 +2,66 @@ namespace Rareloop\Lumberjack\Autodiscovery; -use Rareloop\Lumberjack\Application; -use Rareloop\Lumberjack\Bootstrappers\LoadConfiguration; +use Composer\Script\Event; +use Illuminate\Support\Arr; +use Symfony\Component\Filesystem\Filesystem; class DiscoveryRunner { /** - * Run the discovery process + * Run the discovery process. * - * @param Application $app * @return void */ - public function run(Application $app): void + public function __invoke(Event $event): void { - // Explicitly load configuration as it won't have been loaded by default - (new LoadConfiguration)->bootstrap($app); + $composer = $event->getComposer(); + $io = $event->getIO(); + $vendorPath = $composer->getConfig()->get('vendor-dir'); + $projectPath = dirname($vendorPath); + $extra = $composer->getPackage()->getExtra(); - $app->get(AutodiscoveredPackages::class)->refresh(); + try { + $themePath = $this->resolveThemeDirectory($projectPath, $extra); + + $filesystem = new Filesystem(); + $builder = new PackageManifest($filesystem, $projectPath, $vendorPath); + $cache = new ManifestCache($filesystem, $themePath . DIRECTORY_SEPARATOR . 'bootstrap' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'packages.php'); + + $cache->write($builder->build()); + } catch (\RuntimeException $e) { + $io->writeError("Lumberjack: {$e->getMessage()} Package auto-discovery won't work as expected."); + } + } + + /** + * Resolve the theme directory for discovery. + * + * @param string $projectPath + * @param array $extra + * @return string + * @throws \RuntimeException + */ + protected function resolveThemeDirectory(string $projectPath, array $extra): string + { + $themeDir = Arr::get($extra, 'lumberjack.theme-dir'); + + if ($themeDir) { + $path = $projectPath . DIRECTORY_SEPARATOR . $themeDir; + + if (is_dir($path)) { + return $path; + } + + throw new \RuntimeException("The configured theme directory \"{$path}\" does not exist."); + } + + $defaultPath = $projectPath . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'themes' . DIRECTORY_SEPARATOR . 'lumberjack'; + + if (is_dir($defaultPath)) { + return $defaultPath; + } + + throw new \RuntimeException('"extra.lumberjack.theme-dir" is not set in composer.json and the default path was not found.'); } } diff --git a/src/Autodiscovery/PackageManifest.php b/src/Autodiscovery/PackageManifest.php index 88ebb282..18a0e827 100644 --- a/src/Autodiscovery/PackageManifest.php +++ b/src/Autodiscovery/PackageManifest.php @@ -3,7 +3,6 @@ namespace Rareloop\Lumberjack\Autodiscovery; use Illuminate\Support\Arr; -use Illuminate\Support\Collection; use Symfony\Component\Filesystem\Filesystem; class PackageManifest @@ -15,21 +14,63 @@ public function __construct( ) { } + /** + * Build the manifest of autodiscovered packages. + * + * @return array + */ public function build(): array { $packages = $this->getInstalledPackages(); $ignore = $this->getPackagesToIgnore(); - return Collection::make($packages) - ->mapWithKeys(fn ($package) => [Arr::get($package, 'name') => Arr::get($package, 'extra.lumberjack', [])]) - ->reject(fn ($extra, $name) => $this->shouldIgnore($name, $ignore)) - ->filter() - ->reduce(function ($carry, $extra) { - return [ - 'providers' => array_merge(Arr::get($carry, 'providers', []), Arr::get($extra, 'providers', [])), - 'aliases' => array_merge(Arr::get($carry, 'aliases', []), Arr::get($extra, 'aliases', [])), - ]; - }, ['providers' => [], 'aliases' => []]); + $providers = []; + $aliases = []; + + foreach ($packages as $package) { + $name = Arr::get($package, 'name'); + + if ($this->shouldIgnore($name, $ignore)) { + continue; + } + + $extra = Arr::get($package, 'extra.lumberjack', []); + + if (!is_array($extra) || empty($extra)) { + continue; + } + + $packageProviders = Arr::get($extra, 'providers', []); + $packageAliases = Arr::get($extra, 'aliases', []); + + if (is_array($packageProviders)) { + foreach ($packageProviders as $provider) { + $providers[] = $this->formatClassName($provider); + } + } + + if (is_array($packageAliases)) { + foreach ($packageAliases as $alias => $className) { + $aliases[$alias] = $this->formatClassName($className); + } + } + } + + return [ + 'providers' => array_values(array_unique($providers)), + 'aliases' => $aliases, + ]; + } + + /** + * Format a class name, stripping accidental '::class' suffixes. + * + * @param mixed $className + * @return string + */ + protected function formatClassName(mixed $className): string + { + return str_replace('::class', '', (string) $className); } public function mtime(): int @@ -49,7 +90,13 @@ protected function getInstalledPackages(): array $installed = json_decode(file_get_contents($path), true); - return Arr::get($installed, 'packages', $installed); + if (!is_array($installed)) { + return []; + } + + $packages = $installed['packages'] ?? $installed; + + return is_array($packages) ? $packages : []; } protected function getPackagesToIgnore(): array @@ -62,11 +109,17 @@ protected function getPackagesToIgnore(): array $composer = json_decode(file_get_contents($path), true); - return Arr::get($composer, 'extra.lumberjack.dont-discover', []); + if (!is_array($composer)) { + return []; + } + + $ignore = Arr::get($composer, 'extra.lumberjack.dont-discover', []); + + return is_array($ignore) ? $ignore : []; } - protected function shouldIgnore(string $name, array $ignore): bool + protected function shouldIgnore(?string $name, array $ignore): bool { - return in_array($name, $ignore) || in_array('*', $ignore); + return $name !== null && (in_array($name, $ignore) || in_array('*', $ignore)); } } diff --git a/src/ComposerHooks.php b/src/ComposerHooks.php index ab61bbb6..89d1cbe7 100644 --- a/src/ComposerHooks.php +++ b/src/ComposerHooks.php @@ -7,17 +7,19 @@ class ComposerHooks { /** - * Static helper for composer post-autoload-dump hook + * Handle the post-autoload-dump hook. * - * @codeCoverageIgnore * @param mixed $event * @return void */ public static function postAutoloadDump($event): void { $vendorPath = $event->getComposer()->getConfig()->get('vendor-dir'); - $basePath = dirname($vendorPath); - (new DiscoveryRunner())->run(new Application($basePath)); + if (file_exists($vendorPath . '/autoload.php')) { + require_once $vendorPath . '/autoload.php'; + } + + (new DiscoveryRunner)($event); } } diff --git a/tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php b/tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php index b93d0498..d5130aef 100644 --- a/tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php +++ b/tests/Unit/Autodiscovery/AutodiscoveredPackagesTest.php @@ -4,38 +4,30 @@ use Mockery; use PHPUnit\Framework\TestCase; -use Rareloop\Lumberjack\Application; use Rareloop\Lumberjack\Autodiscovery\AutodiscoveredPackages; use Rareloop\Lumberjack\Autodiscovery\ManifestCache; -use Rareloop\Lumberjack\Autodiscovery\PackageManifest; class AutodiscoveredPackagesTest extends TestCase { use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; - protected $builder; protected $cache; - protected $app; protected function setUp(): void { parent::setUp(); - $this->builder = Mockery::mock(PackageManifest::class); $this->cache = Mockery::mock(ManifestCache::class); - $this->app = Mockery::mock(Application::class); } /** @test */ - public function it_reads_from_cache_if_not_stale() + public function it_reads_from_cache_if_it_exists() { $manifestData = ['providers' => ['Cached\Provider']]; $this->cache->shouldReceive('exists')->andReturn(true); - $this->builder->shouldReceive('mtime')->andReturn(100); - $this->cache->shouldReceive('mtime')->andReturn(200); $this->cache->shouldReceive('read')->once()->andReturn($manifestData); - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + $orchestrator = new AutodiscoveredPackages($this->cache); $this->assertEquals(['Cached\Provider'], $orchestrator->providers()); } @@ -46,131 +38,59 @@ public function it_can_get_aliases() $manifestData = ['aliases' => ['Foo' => 'Bar']]; $this->cache->shouldReceive('exists')->andReturn(true); - $this->builder->shouldReceive('mtime')->andReturn(100); - $this->cache->shouldReceive('mtime')->andReturn(200); $this->cache->shouldReceive('read')->once()->andReturn($manifestData); - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + $orchestrator = new AutodiscoveredPackages($this->cache); $this->assertEquals(['Foo' => 'Bar'], $orchestrator->aliases()); } /** @test */ - public function it_refreshes_cache_if_stale() + public function it_returns_empty_arrays_if_cache_missing() { - $manifestData = ['providers' => ['Fresh\Provider']]; - - $this->cache->shouldReceive('exists')->andReturn(true); - $this->builder->shouldReceive('mtime')->andReturn(300); - $this->cache->shouldReceive('mtime')->andReturn(200); - - $this->builder->shouldReceive('build')->once()->andReturn($manifestData); - $this->cache->shouldReceive('write')->once()->with($manifestData); - - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); - - $this->assertEquals(['Fresh\Provider'], $orchestrator->providers()); - } - - /** @test */ - public function it_refreshes_cache_if_cache_missing() - { - $manifestData = ['providers' => ['Fresh\Provider']]; - - $this->cache->shouldReceive('exists')->andReturn(false); - $this->builder->shouldReceive('build')->once()->andReturn($manifestData); - $this->cache->shouldReceive('write')->once()->with($manifestData); - - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); - - $this->assertEquals(['Fresh\Provider'], $orchestrator->providers()); - } - - /** @test */ - public function it_logs_a_warning_if_cache_is_unwritable_and_debug_is_enabled() - { - $manifestData = ['providers' => ['Fresh\Provider']]; - $this->cache->shouldReceive('exists')->andReturn(false); - $this->builder->shouldReceive('build')->andReturn($manifestData); - $this->cache->shouldReceive('getPath')->andReturn('/path/to/cache'); - // Simulate write failure - $this->cache->shouldReceive('write')->andThrow(new \Symfony\Component\Filesystem\Exception\IOException('Unwritable')); + $orchestrator = new AutodiscoveredPackages($this->cache); - $logger = Mockery::mock(\Psr\Log\LoggerInterface::class); - $logger->shouldReceive('warning')->once()->with("The /path/to/cache directory is not writable. Please check your permissions."); - - $this->app->shouldReceive('has')->with(\Psr\Log\LoggerInterface::class)->andReturn(true); - $this->app->shouldReceive('get')->with(\Psr\Log\LoggerInterface::class)->andReturn($logger); - - // Explicitly set debug to true - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, true); - $orchestrator->refresh(); + $this->assertEquals([], $orchestrator->providers()); + $this->assertEquals([], $orchestrator->aliases()); } /** @test */ - public function it_does_not_log_a_warning_if_debug_is_disabled() + public function manifest_is_only_loaded_once() { - $manifestData = ['providers' => ['Fresh\Provider']]; - - $this->cache->shouldReceive('exists')->andReturn(false); - $this->builder->shouldReceive('build')->andReturn($manifestData); - $this->cache->shouldReceive('getPath')->andReturn('/path/to/cache'); + $manifestData = ['providers' => []]; - // Simulate write failure - $this->cache->shouldReceive('write')->andThrow(new \Symfony\Component\Filesystem\Exception\IOException('Unwritable')); + $this->cache->shouldReceive('exists')->andReturn(true); + $this->cache->shouldReceive('read')->once()->andReturn($manifestData); - $this->app->shouldNotReceive('has'); - $this->app->shouldNotReceive('get'); + $orchestrator = new AutodiscoveredPackages($this->cache); - // Explicitly set debug to false - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, false); - $orchestrator->refresh(); + $orchestrator->providers(); + $orchestrator->providers(); // Second call should not trigger 'read' + + $this->assertEquals([], $orchestrator->providers()); } /** @test */ - public function it_does_not_log_if_logger_is_missing() + public function providers_always_returns_an_array_even_if_manifest_corrupted() { - $manifestData = ['providers' => ['Fresh\Provider']]; - - $this->cache->shouldReceive('exists')->andReturn(false); - $this->builder->shouldReceive('build')->andReturn($manifestData); - $this->cache->shouldReceive('getPath')->andReturn('/path/to/cache'); - - // Simulate write failure - $this->cache->shouldReceive('write')->andThrow(new \Symfony\Component\Filesystem\Exception\IOException('Unwritable')); + $this->cache->shouldReceive('exists')->andReturn(true); + $this->cache->shouldReceive('read')->andReturn(['providers' => null]); - $this->app->shouldReceive('has')->with(\Psr\Log\LoggerInterface::class)->andReturn(false); - $this->app->shouldNotReceive('get'); + $orchestrator = new AutodiscoveredPackages($this->cache); - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, true); - $orchestrator->refresh(); + $this->assertEquals([], $orchestrator->providers()); } /** @test */ - public function manifest_is_only_loaded_once() + public function aliases_always_returns_an_array_even_if_manifest_corrupted() { - $manifestData = ['providers' => []]; - $this->cache->shouldReceive('exists')->andReturn(true); - $this->builder->shouldReceive('mtime')->andReturn(100); - $this->cache->shouldReceive('mtime')->andReturn(200); - $this->cache->shouldReceive('read')->once()->andReturn($manifestData); - - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app); + $this->cache->shouldReceive('read')->andReturn(['aliases' => null]); - $orchestrator->providers(); - $orchestrator->providers(); // Second call should not trigger 'read' - - // Asserting equality to trigger PHPUnit count - $this->assertEquals([], $orchestrator->providers()); - } + $orchestrator = new AutodiscoveredPackages($this->cache); - /** @test */ - public function debug_flag_is_accessible() - { - $orchestrator = new AutodiscoveredPackages($this->builder, $this->cache, $this->app, true); - $this->assertTrue($orchestrator->debug); + $this->assertEquals([], $orchestrator->aliases()); } } diff --git a/tests/Unit/Autodiscovery/DiscoveryRunnerTest.php b/tests/Unit/Autodiscovery/DiscoveryRunnerTest.php index 87a10119..2d47e840 100644 --- a/tests/Unit/Autodiscovery/DiscoveryRunnerTest.php +++ b/tests/Unit/Autodiscovery/DiscoveryRunnerTest.php @@ -2,37 +2,111 @@ namespace Rareloop\Lumberjack\Test\Unit\Autodiscovery; +use Composer\Composer; +use Composer\Config; +use Composer\IO\IOInterface; +use Composer\Package\RootPackageInterface; +use Composer\Script\Event; use Mockery; use PHPUnit\Framework\TestCase; -use Rareloop\Lumberjack\Application; -use Rareloop\Lumberjack\Autodiscovery\AutodiscoveredPackages; use Rareloop\Lumberjack\Autodiscovery\DiscoveryRunner; -use Rareloop\Lumberjack\Config; use org\bovigo\vfs\vfsStream; class DiscoveryRunnerTest extends TestCase { use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; - /** @test */ - public function it_can_run_the_discovery_process() + protected $root; + protected $event; + protected $composer; + protected $config; + protected $package; + protected $io; + + protected function setUp(): void { - $root = vfsStream::setup('root', null, [ - 'config' => [], + parent::setUp(); + + $this->root = vfsStream::setup('root', null, [ + 'vendor' => [ + 'composer' => [ + 'installed.json' => json_encode(['packages' => []]), + ], + ], ]); - $app = Mockery::mock(Application::class); - $app->shouldReceive('configPath')->andReturn($root->url() . '/config'); + $this->event = Mockery::mock(Event::class); + $this->composer = Mockery::mock(Composer::class); + $this->config = Mockery::mock(Config::class); + $this->package = Mockery::mock(RootPackageInterface::class); - // LoadConfiguration expects these bindings to be possible - $app->shouldReceive('bind')->with('config', Mockery::type(Config::class)); - $app->shouldReceive('bind')->with(Config::class, Mockery::type(Config::class)); + $this->io = Mockery::mock(IOInterface::class); + $this->io->shouldIgnoreMissing(); + + $this->event->shouldReceive('getComposer')->andReturn($this->composer); + $this->event->shouldReceive('getIO')->andReturn($this->io); + $this->composer->shouldReceive('getConfig')->andReturn($this->config); + $this->composer->shouldReceive('getPackage')->andReturn($this->package); + $this->config->shouldReceive('get')->with('vendor-dir')->andReturn($this->root->url() . '/vendor'); + } + + /** @test */ + public function it_can_run_the_discovery_process_with_explicit_config() + { + vfsStream::newDirectory('my-app/bootstrap/cache')->at($this->root); + + $this->package->shouldReceive('getExtra')->andReturn([ + 'lumberjack' => [ + 'theme-dir' => 'my-app', + ], + ]); + $this->io->shouldNotReceive('writeError'); + + (new DiscoveryRunner)($this->event); + + $this->assertTrue($this->root->hasChild('my-app/bootstrap/cache/packages.php')); + } + + /** @test */ + public function it_can_run_the_discovery_process_using_default_bedrock_path() + { + vfsStream::newDirectory('web/app/themes/lumberjack/bootstrap/cache')->at($this->root); + + $this->package->shouldReceive('getExtra')->andReturn([]); + $this->io->shouldNotReceive('writeError'); + + (new DiscoveryRunner)($this->event); + + $this->assertTrue($this->root->hasChild('web/app/themes/lumberjack/bootstrap/cache/packages.php')); + } + + /** @test */ + public function it_emits_warning_if_no_config_and_no_bedrock_path_found() + { + $this->package->shouldReceive('getExtra')->andReturn([]); - $orchestrator = Mockery::mock(AutodiscoveredPackages::class); - $orchestrator->shouldReceive('refresh')->once(); + $this->io->shouldReceive('writeError')->once()->with(Mockery::on(function ($message) { + return str_contains($message, 'default path was not found') && str_contains($message, 'Package auto-discovery won\'t work'); + })); + + (new DiscoveryRunner)($this->event); + + $this->assertFalse($this->root->hasChild('bootstrap/cache/packages.php')); + } + + /** @test */ + public function it_emits_error_if_configured_path_is_missing() + { + $this->package->shouldReceive('getExtra')->andReturn([ + 'lumberjack' => [ + 'theme-dir' => 'custom-app', + ], + ]); - $app->shouldReceive('get')->with(AutodiscoveredPackages::class)->andReturn($orchestrator); + $this->io->shouldReceive('writeError')->once()->with(Mockery::on(function ($message) { + return str_contains($message, 'configured theme directory') && str_contains($message, 'does not exist'); + })); - (new DiscoveryRunner())->run($app); + (new DiscoveryRunner)($this->event); } } diff --git a/tests/Unit/Autodiscovery/PackageManifestTest.php b/tests/Unit/Autodiscovery/PackageManifestTest.php index 7cedb93c..91921f21 100644 --- a/tests/Unit/Autodiscovery/PackageManifestTest.php +++ b/tests/Unit/Autodiscovery/PackageManifestTest.php @@ -22,11 +22,15 @@ protected function setUp(): void 'installed.json' => json_encode([ 'packages' => [ [ - 'name' => 'package/one', + 'name' => 'rareloop/lumberjack-test-package', 'extra' => [ 'lumberjack' => [ - 'providers' => ['Package\One\ServiceProvider'], - 'aliases' => ['One' => 'Package\One\Facade'], + 'providers' => [ + 'Rareloop\Lumberjack\Validation\ValidationServiceProvider' + ], + 'aliases' => [ + 'test-foo' => 'Rareloop\Lumberjack\Validation\FormInterface' + ], ], ], ], @@ -41,7 +45,7 @@ protected function setUp(): void } /** @test */ - public function it_can_build_the_manifest() + public function it_can_discover_aliases_with_hyphens() { $manifest = new PackageManifest( $this->filesystem, @@ -51,108 +55,8 @@ public function it_can_build_the_manifest() $data = $manifest->build(); - $this->assertEquals(['Package\One\ServiceProvider'], $data['providers']); - $this->assertEquals(['One' => 'Package\One\Facade'], $data['aliases']); - } - - /** @test */ - public function it_respects_dont_discover() - { - $this->root->getChild('composer.json')->setContent(json_encode([ - 'extra' => [ - 'lumberjack' => [ - 'dont-discover' => ['package/one'], - ], - ], - ])); - - $manifest = new PackageManifest( - $this->filesystem, - $this->root->url(), - $this->root->url() . '/vendor' - ); - - $data = $manifest->build(); - - $this->assertEmpty($data['providers']); - $this->assertEmpty($data['aliases']); - } - - /** @test */ - public function it_respects_wildcard_dont_discover() - { - $this->root->getChild('composer.json')->setContent(json_encode([ - 'extra' => [ - 'lumberjack' => [ - 'dont-discover' => ['*'], - ], - ], - ])); - - $manifest = new PackageManifest( - $this->filesystem, - $this->root->url(), - $this->root->url() . '/vendor' - ); - - $data = $manifest->build(); - - $this->assertEmpty($data['providers']); - } - - /** @test */ - public function it_can_get_the_mtime_of_installed_json() - { - $manifest = new PackageManifest( - $this->filesystem, - $this->root->url(), - $this->root->url() . '/vendor' - ); - - $this->assertGreaterThan(0, $manifest->mtime()); - } - - /** @test */ - public function it_returns_zero_mtime_if_installed_json_missing() - { - $this->root->getChild('vendor/composer')->removeChild('installed.json'); - - $manifest = new PackageManifest( - $this->filesystem, - $this->root->url(), - $this->root->url() . '/vendor' - ); - - $this->assertEquals(0, $manifest->mtime()); - } - - /** @test */ - public function it_returns_empty_packages_if_installed_json_missing() - { - $this->root->getChild('vendor/composer')->removeChild('installed.json'); - - $manifest = new PackageManifest( - $this->filesystem, - $this->root->url(), - $this->root->url() . '/vendor' - ); - - $data = $manifest->build(); - $this->assertEmpty($data['providers']); - } - - /** @test */ - public function it_returns_empty_ignore_list_if_composer_json_missing() - { - $this->root->removeChild('composer.json'); - - $manifest = new PackageManifest( - $this->filesystem, - $this->root->url(), - $this->root->url() . '/vendor' - ); - - $data = $manifest->build(); - $this->assertEquals(['Package\One\ServiceProvider'], $data['providers']); + $this->assertContains('Rareloop\Lumberjack\Validation\ValidationServiceProvider', $data['providers']); + $this->assertArrayHasKey('test-foo', $data['aliases']); + $this->assertEquals('Rareloop\Lumberjack\Validation\FormInterface', $data['aliases']['test-foo']); } } From bfa7e931c3e97acb5950b24231ef2ddbbf0550de Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Thu, 7 May 2026 08:53:53 +0100 Subject: [PATCH 3/5] Switch to native filesystem operations and preserve associative alias keys --- src/Autodiscovery/DiscoveryRunner.php | 6 ++---- src/Autodiscovery/ManifestCache.php | 18 ++++++++++-------- src/Autodiscovery/PackageManifest.php | 8 +++----- tests/Unit/Autodiscovery/ManifestCacheTest.php | 15 ++++++--------- .../Unit/Autodiscovery/PackageManifestTest.php | 5 ----- 5 files changed, 21 insertions(+), 31 deletions(-) diff --git a/src/Autodiscovery/DiscoveryRunner.php b/src/Autodiscovery/DiscoveryRunner.php index 030d275d..9a3ec250 100644 --- a/src/Autodiscovery/DiscoveryRunner.php +++ b/src/Autodiscovery/DiscoveryRunner.php @@ -4,7 +4,6 @@ use Composer\Script\Event; use Illuminate\Support\Arr; -use Symfony\Component\Filesystem\Filesystem; class DiscoveryRunner { @@ -24,9 +23,8 @@ public function __invoke(Event $event): void try { $themePath = $this->resolveThemeDirectory($projectPath, $extra); - $filesystem = new Filesystem(); - $builder = new PackageManifest($filesystem, $projectPath, $vendorPath); - $cache = new ManifestCache($filesystem, $themePath . DIRECTORY_SEPARATOR . 'bootstrap' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'packages.php'); + $builder = new PackageManifest($projectPath, $vendorPath); + $cache = new ManifestCache($themePath . DIRECTORY_SEPARATOR . 'bootstrap' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'packages.php'); $cache->write($builder->build()); } catch (\RuntimeException $e) { diff --git a/src/Autodiscovery/ManifestCache.php b/src/Autodiscovery/ManifestCache.php index e6d2aedb..c664e80c 100644 --- a/src/Autodiscovery/ManifestCache.php +++ b/src/Autodiscovery/ManifestCache.php @@ -2,19 +2,15 @@ namespace Rareloop\Lumberjack\Autodiscovery; -use Symfony\Component\Filesystem\Filesystem; - class ManifestCache { - public function __construct( - protected Filesystem $filesystem, - protected string $cachePath - ) { + public function __construct(protected string $cachePath) + { } public function exists(): bool { - return $this->filesystem->exists($this->cachePath); + return file_exists($this->cachePath); } public function read(): array @@ -26,7 +22,13 @@ public function read(): array public function write(array $manifest): void { - $this->filesystem->dumpFile( + $directory = dirname($this->cachePath); + + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents( $this->cachePath, 'vendorPath . DIRECTORY_SEPARATOR . 'composer' . DIRECTORY_SEPARATOR . 'installed.json'; - return $this->filesystem->exists($path) ? filemtime($path) : 0; + return file_exists($path) ? filemtime($path) : 0; } protected function getInstalledPackages(): array { $path = $this->vendorPath . DIRECTORY_SEPARATOR . 'composer' . DIRECTORY_SEPARATOR . 'installed.json'; - if (!$this->filesystem->exists($path)) { + if (!file_exists($path)) { return []; } @@ -103,7 +101,7 @@ protected function getPackagesToIgnore(): array { $path = $this->basePath . DIRECTORY_SEPARATOR . 'composer.json'; - if (!$this->filesystem->exists($path)) { + if (!file_exists($path)) { return []; } diff --git a/tests/Unit/Autodiscovery/ManifestCacheTest.php b/tests/Unit/Autodiscovery/ManifestCacheTest.php index baca5b6f..733ae4dd 100644 --- a/tests/Unit/Autodiscovery/ManifestCacheTest.php +++ b/tests/Unit/Autodiscovery/ManifestCacheTest.php @@ -4,26 +4,23 @@ use PHPUnit\Framework\TestCase; use Rareloop\Lumberjack\Autodiscovery\ManifestCache; -use Symfony\Component\Filesystem\Filesystem; use org\bovigo\vfs\vfsStream; class ManifestCacheTest extends TestCase { protected $root; - protected $filesystem; protected function setUp(): void { parent::setUp(); $this->root = vfsStream::setup('root'); - $this->filesystem = new Filesystem(); } /** @test */ public function it_can_check_if_cache_exists() { $cachePath = $this->root->url() . '/packages.php'; - $cache = new ManifestCache($this->filesystem, $cachePath); + $cache = new ManifestCache($cachePath); $this->assertFalse($cache->exists()); @@ -35,7 +32,7 @@ public function it_can_check_if_cache_exists() public function it_can_write_the_cache() { $cachePath = $this->root->url() . '/packages.php'; - $cache = new ManifestCache($this->filesystem, $cachePath); + $cache = new ManifestCache($cachePath); $manifest = ['providers' => ['Foo\Bar']]; $cache->write($manifest); @@ -49,7 +46,7 @@ public function it_can_write_the_cache() public function it_can_read_the_cache() { $cachePath = $this->root->url() . '/packages.php'; - $cache = new ManifestCache($this->filesystem, $cachePath); + $cache = new ManifestCache($cachePath); $manifest = ['providers' => ['Foo\Bar']]; file_put_contents($cachePath, 'root->url() . '/packages.php'; - $cache = new ManifestCache($this->filesystem, $cachePath); + $cache = new ManifestCache($cachePath); file_put_contents($cachePath, 'root->url() . '/packages.php'; - $cache = new ManifestCache($this->filesystem, $cachePath); + $cache = new ManifestCache($cachePath); $this->assertEquals(0, $cache->mtime()); @@ -84,7 +81,7 @@ public function it_can_get_the_mtime() public function it_can_get_the_path() { $cachePath = '/path/to/cache.php'; - $cache = new ManifestCache($this->filesystem, $cachePath); + $cache = new ManifestCache($cachePath); $this->assertEquals($cachePath, $cache->getPath()); } diff --git a/tests/Unit/Autodiscovery/PackageManifestTest.php b/tests/Unit/Autodiscovery/PackageManifestTest.php index 91921f21..5e6a60f6 100644 --- a/tests/Unit/Autodiscovery/PackageManifestTest.php +++ b/tests/Unit/Autodiscovery/PackageManifestTest.php @@ -4,13 +4,11 @@ use PHPUnit\Framework\TestCase; use Rareloop\Lumberjack\Autodiscovery\PackageManifest; -use Symfony\Component\Filesystem\Filesystem; use org\bovigo\vfs\vfsStream; class PackageManifestTest extends TestCase { protected $root; - protected $filesystem; protected function setUp(): void { @@ -40,15 +38,12 @@ protected function setUp(): void ], 'composer.json' => json_encode([]), ]); - - $this->filesystem = new Filesystem(); } /** @test */ public function it_can_discover_aliases_with_hyphens() { $manifest = new PackageManifest( - $this->filesystem, $this->root->url(), $this->root->url() . '/vendor' ); From 9f33dbfa0860c53ef9bd81b7fff4e395210f43cd Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Thu, 7 May 2026 09:03:02 +0100 Subject: [PATCH 4/5] Fix PSR2 line length warnings in DiscoveryRunner --- src/Autodiscovery/DiscoveryRunner.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Autodiscovery/DiscoveryRunner.php b/src/Autodiscovery/DiscoveryRunner.php index 9a3ec250..d2c385aa 100644 --- a/src/Autodiscovery/DiscoveryRunner.php +++ b/src/Autodiscovery/DiscoveryRunner.php @@ -24,11 +24,15 @@ public function __invoke(Event $event): void $themePath = $this->resolveThemeDirectory($projectPath, $extra); $builder = new PackageManifest($projectPath, $vendorPath); - $cache = new ManifestCache($themePath . DIRECTORY_SEPARATOR . 'bootstrap' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'packages.php'); + $cachePath = $themePath . DIRECTORY_SEPARATOR . 'bootstrap' . DIRECTORY_SEPARATOR . 'cache' + . DIRECTORY_SEPARATOR . 'packages.php'; + $cache = new ManifestCache($cachePath); $cache->write($builder->build()); } catch (\RuntimeException $e) { - $io->writeError("Lumberjack: {$e->getMessage()} Package auto-discovery won't work as expected."); + $io->writeError( + "Lumberjack: {$e->getMessage()} Package auto-discovery won't work as expected." + ); } } @@ -54,12 +58,15 @@ protected function resolveThemeDirectory(string $projectPath, array $extra): str throw new \RuntimeException("The configured theme directory \"{$path}\" does not exist."); } - $defaultPath = $projectPath . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'themes' . DIRECTORY_SEPARATOR . 'lumberjack'; + $defaultPath = $projectPath . DIRECTORY_SEPARATOR . 'web' . DIRECTORY_SEPARATOR . 'app' + . DIRECTORY_SEPARATOR . 'themes' . DIRECTORY_SEPARATOR . 'lumberjack'; if (is_dir($defaultPath)) { return $defaultPath; } - throw new \RuntimeException('"extra.lumberjack.theme-dir" is not set in composer.json and the default path was not found.'); + throw new \RuntimeException( + '"extra.lumberjack.theme-dir" is not set in composer.json and the default path was not found.' + ); } } From f120805cf36945c7462064544611dd8ae19f4069 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Thu, 7 May 2026 09:25:18 +0100 Subject: [PATCH 5/5] Remove useVendorPath and simplify vendor path logic --- src/Application.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Application.php b/src/Application.php index 2168a968..ee58a77f 100644 --- a/src/Application.php +++ b/src/Application.php @@ -19,7 +19,6 @@ class Application implements ContainerInterface private $loadedProviders = []; private $booted = false; private $basePath; - private $vendorPath; private $requestHandled = false; private $nonSingletonClassBinds = []; @@ -45,13 +44,6 @@ public function setBasePath(string $basePath) $this->bootstrapContainer(); } - public function useVendorPath(string $path) - { - $this->vendorPath = $path; - - $this->bootstrapContainer(); - } - protected function bootstrapContainer() { $this->bindPathsInContainer(); @@ -87,7 +79,7 @@ public function configPath() public function vendorPath() { - return $this->vendorPath ?: $this->basePath . DIRECTORY_SEPARATOR . 'vendor'; + return $this->basePath . DIRECTORY_SEPARATOR . 'vendor'; } public function bootstrapPath(string $path = '')