diff --git a/docs/3-packages/02-console.md b/docs/3-packages/02-console.md index bb67fb1534..fc7eddea3f 100644 --- a/docs/3-packages/02-console.md +++ b/docs/3-packages/02-console.md @@ -35,7 +35,7 @@ You may read more about building commands in the [dedicated documentation](../1- Tempest will automatically discover all console commands from multiple sources: 1. **Core Tempest packages** — Built-in commands from Tempest itself -2. **Vendor packages** — Third-party packages that require `tempest/framework` or `tempest/core` +2. **Vendor packages** — Packages that require any `tempest/*` package,or opt in via `extra.tempest.can-discover` 3. **App namespaces** — All namespaces configured as PSR-4 autoload paths in your `composer.json` ```json diff --git a/docs/5-extra-topics/01-package-development.md b/docs/5-extra-topics/01-package-development.md index 97d4eaa7f9..7236c10e36 100644 --- a/docs/5-extra-topics/01-package-development.md +++ b/docs/5-extra-topics/01-package-development.md @@ -5,10 +5,26 @@ description: "Tempest comes with a handful of tools to help third-party package ## Overview -Creating a package for Tempest is as simple as adding `tempest/core` as a dependency. When this happens, [discovery](../1-essentials/05-discovery.md) will find the package thanks to composer metadata and register discoverable classes. +Creating a package for Tempest consists of creating a typical PHP package, except it should depend on the relevant Tempest dependency. When you install a dependency that depends on any `tempest/*` package, [discovery](../1-essentials/05-discovery.md) will find it through Composer metadata and register discoverable classes. Unlike Symfony or Laravel, Tempest doesn't have a dedicated "service provider" concept. Instead, you're encouraged to rely on [discovery](../1-essentials/05-discovery.md) and [initializers](../1-essentials/05-container#dependency-initializers). +## Optional Tempest support + +If your package is a normal package but has optional support for Tempest, you can opt-in for discovery by providing metadata in `composer.json`: + +```json composer.json +{ + "extra": { + "tempest": { + "can-discover": true + } + } +} +``` + +The `extra.tempest.can-discover` property marks your package as discoverable even without a Tempest dependency. + ## Preventing discovery You may create classes which would normally be discovered by Tempest. You may prevent this behavior by marking them with the {`Tempest\Discovery\SkipDiscovery`} attribute. @@ -25,6 +41,20 @@ final readonly class UserMigration implements Migration } ``` +Alternatively, you may use composer metadata to completely exclude any path from discovery. This is mostly useful when the package has optional dependencies, since discovery use Reflection and will throw errors when encountering unknown classes or interfaces. + +```json composer.json +{ + "extra": { + "tempest": { + "ignore": [ + "src/OptionalDependency.php" + ] + } + } +} +``` + ## Installers An installer is a command that publishes files to the user's project. For instance, this can be used to export migration files that shouldn't be discovered unless the user have published them. diff --git a/packages/discovery/src/AutoloadDiscoveryLocations.php b/packages/discovery/src/AutoloadDiscoveryLocations.php index bba4f00963..fe0f468178 100644 --- a/packages/discovery/src/AutoloadDiscoveryLocations.php +++ b/packages/discovery/src/AutoloadDiscoveryLocations.php @@ -5,8 +5,7 @@ namespace Tempest\Discovery; use Tempest\Support\Filesystem; - -use function Tempest\Support\Path\normalize; +use Tempest\Support\Path; final readonly class AutoloadDiscoveryLocations { @@ -27,42 +26,47 @@ public function __construct( /** @return \Tempest\Discovery\DiscoveryLocation[] */ public function __invoke(): array { + $composerPath = Path\normalize($this->rootPath, 'vendor/composer'); + $installed = $this->loadJsonFile(Path\normalize($composerPath, 'installed.json')); + $packages = $installed['packages'] ?? []; + return [ - ...$this->discoverCorePackages(), - ...$this->discoverVendorPackages(), + ...$this->discoverInstalledPackages($composerPath, $packages), ...$this->discoverAppNamespaces(), ]; } - /** - * @return DiscoveryLocation[] - */ - private function discoverCorePackages(): array + /** @return DiscoveryLocation[] */ + private function discoverInstalledPackages(string $composerPath, array $packages): array { - $composerPath = normalize($this->rootPath, 'vendor/composer'); - $installed = $this->loadJsonFile(normalize($composerPath, 'installed.json')); - $packages = $installed['packages'] ?? []; - - $discoveredLocations = []; + $core = []; + $vendor = []; + $optIn = []; foreach ($packages as $package) { - $packageName = $package['name'] ?? ''; - $isTempest = str_starts_with($packageName, 'tempest'); - - if (! $isTempest) { + if (! isset($package['autoload']['psr-4'])) { continue; } - $packagePath = normalize($composerPath, $package['install-path'] ?? ''); + $packagePath = Path\normalize($composerPath, $package['install-path'] ?? ''); - foreach ($package['autoload']['psr-4'] as $namespace => $namespacePath) { - $namespacePath = normalize($packagePath, $namespacePath); + if (str_starts_with($package['name'] ?? '', needle: 'tempest/')) { + $core = [...$core, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'])]; + continue; + } - $discoveredLocations[] = new DiscoveryLocation($namespace, $namespacePath); + if (array_find($package['require'] ?? [], static fn ($_, string $package) => str_starts_with($package, needle: 'tempest/'))) { + $vendor = [...$vendor, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'])]; + continue; + } + + if ($package['extra']['tempest']['can-discover'] ?? false) { + $optIn = [...$optIn, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'], $package['extra']['tempest']['ignore'] ?? [])]; + continue; } } - return $discoveredLocations; + return [...$core, ...$vendor, ...$optIn]; } /** @@ -73,7 +77,7 @@ private function discoverAppNamespaces(): array $discoveredLocations = []; foreach ($this->composer->namespaces as $namespace) { - $path = normalize($this->rootPath, $namespace->path); + $path = Path\normalize($this->rootPath, $namespace->path); $discoveredLocations[] = new DiscoveryLocation($namespace->namespace, $path); } @@ -81,37 +85,27 @@ private function discoverAppNamespaces(): array return $discoveredLocations; } - /** - * @return DiscoveryLocation[] - */ - private function discoverVendorPackages(): array + /** @return DiscoveryLocation[] */ + private function discoverPackageLocations(string $packagePath, array $psr4Namespaces, array $ignore = []): array { - $composerPath = normalize($this->rootPath, 'vendor/composer'); - $installed = $this->loadJsonFile(normalize($composerPath, 'installed.json')); - $packages = $installed['packages'] ?? []; - $discoveredLocations = []; + $ignore = array_map(static fn (string $path) => Filesystem\normalize_path(Path\normalize($packagePath, $path)), $ignore); - foreach ($packages as $package) { - $packageName = $package['name'] ?? ''; - $isTempest = str_starts_with($packageName, 'tempest'); - - if ($isTempest) { - continue; - } + foreach ($psr4Namespaces as $namespace => $namespacePath) { + if (is_array($namespacePath)) { + foreach ($namespacePath as $path) { + if (! is_string($path)) { + continue; + } - $packagePath = normalize($composerPath, $package['install-path'] ?? ''); - $requiresTempest = isset($package['require']['tempest/discovery']) || isset($package['require']['tempest/framework']) || isset($package['require']['tempest/core']); - $hasPsr4Namespaces = isset($package['autoload']['psr-4']); + $discoveredLocations[] = new DiscoveryLocation($namespace, Path\normalize($packagePath, $path), $ignore); + } - if (! ($requiresTempest && $hasPsr4Namespaces)) { continue; } - foreach ($package['autoload']['psr-4'] as $namespace => $namespacePath) { - $path = normalize($packagePath, $namespacePath); - - $discoveredLocations[] = new DiscoveryLocation($namespace, $path); + if (is_string($namespacePath)) { + $discoveredLocations[] = new DiscoveryLocation($namespace, Path\normalize($packagePath, $namespacePath), $ignore); } } diff --git a/packages/discovery/src/BootDiscovery.php b/packages/discovery/src/BootDiscovery.php index 25f514dee8..70991d530e 100644 --- a/packages/discovery/src/BootDiscovery.php +++ b/packages/discovery/src/BootDiscovery.php @@ -150,6 +150,10 @@ private function scan(DiscoveryLocation $location, array $discoveries, string $p return; } + if ($location->isIgnored($input)) { + return; + } + if (is_file($input)) { $this->discoverPath($input, $location, $discoveries); return; diff --git a/packages/discovery/src/DiscoveryLocation.php b/packages/discovery/src/DiscoveryLocation.php index 84b33b4946..e27e00aab4 100644 --- a/packages/discovery/src/DiscoveryLocation.php +++ b/packages/discovery/src/DiscoveryLocation.php @@ -18,6 +18,7 @@ final class DiscoveryLocation public function __construct( public readonly string $namespace, string $path, + private(set) array $ignore = [], ) { $this->path = Filesystem\normalize_path(rtrim($path, '\\/')); } @@ -37,6 +38,11 @@ public function isVendor(): bool return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\') || $this->isTempest(); } + public function isIgnored(string $path): bool + { + return array_any($this->ignore, fn (string $ignore) => str_starts_with($path, $ignore)); + } + public function toClassName(string $path): string { // Try to create a PSR-compliant class name from the path