Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/3-packages/02-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion docs/5-extra-topics/01-package-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
86 changes: 40 additions & 46 deletions packages/discovery/src/AutoloadDiscoveryLocations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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];
}

/**
Expand All @@ -73,45 +77,35 @@ 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);
}

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);
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/discovery/src/BootDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/discovery/src/DiscoveryLocation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '\\/'));
}
Expand All @@ -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
Expand Down
Loading