diff --git a/app/Commands/PresetListCommand.php b/app/Commands/PresetListCommand.php new file mode 100644 index 00000000..6542f8ad --- /dev/null +++ b/app/Commands/PresetListCommand.php @@ -0,0 +1,36 @@ +names(); + + if ($presets === []) { + $this->components->warn('No presets found.'); + + return self::SUCCESS; + } + + $this->newLine(); + $this->components->twoColumnDetail( + 'Preset', + 'Path', + ); + + foreach ($presets as $preset) { + $path = $presetManifest->path($preset); + $presets[$preset] = $path; + $this->components->twoColumnDetail($preset, $path); + } + + return self::SUCCESS; + } +} diff --git a/app/Factories/ConfigurationResolverFactory.php b/app/Factories/ConfigurationResolverFactory.php index 71fc9d8c..7bfdda22 100644 --- a/app/Factories/ConfigurationResolverFactory.php +++ b/app/Factories/ConfigurationResolverFactory.php @@ -4,6 +4,7 @@ use App\Project; use App\Repositories\ConfigurationJsonRepository; +use App\Services\PresetManifest; use ArrayIterator; use PhpCsFixer\Config; use PhpCsFixer\Console\ConfigurationResolver; @@ -11,19 +12,6 @@ class ConfigurationResolverFactory { - /** - * The list of available presets. - * - * @var array - */ - public static $presets = [ - 'laravel', - 'per', - 'psr12', - 'symfony', - 'empty', - ]; - /** * Creates a new PHP CS Fixer Configuration Resolver instance * from the given input and output. @@ -37,23 +25,20 @@ public static function fromIO($input, $output) $path = Project::paths($input); $localConfiguration = resolve(ConfigurationJsonRepository::class); + $presetManifest = resolve(PresetManifest::class); $preset = $localConfiguration->preset(); - if (! in_array($preset, static::$presets)) { - abort(1, 'Preset not found.'); + if (! $presetManifest->has($preset)) { + $availablePresets = implode(', ', $presetManifest->names()); + abort(1, "Preset '{$preset}' not found. Available presets: {$availablePresets}"); } $resolver = new ConfigurationResolver( new Config('default'), [ 'allow-risky' => 'yes', - 'config' => implode(DIRECTORY_SEPARATOR, [ - dirname(__DIR__, 2), - 'resources', - 'presets', - sprintf('%s.php', $preset), - ]), + 'config' => $presetManifest->path($preset), 'diff' => $output->isVerbose(), 'dry-run' => $input->getOption('test') || $input->getOption('bail'), 'path' => $path, diff --git a/app/Output/SummaryOutput.php b/app/Output/SummaryOutput.php index df42ec70..6a11119c 100644 --- a/app/Output/SummaryOutput.php +++ b/app/Output/SummaryOutput.php @@ -4,7 +4,9 @@ use App\Output\Concerns\InteractsWithSymbols; use App\Project; +use App\Services\PresetManifest; use App\ValueObjects\Issue; +use Illuminate\Support\Str; use PhpCsFixer\Runner\Event\FileProcessed; use function Termwind\render; @@ -15,17 +17,21 @@ class SummaryOutput use InteractsWithSymbols; /** - * The list of presets, in a human-readable format. + * Get the list of presets in a human-readable format. * - * @var array + * @return array */ - protected $presets = [ - 'per' => 'PER', - 'psr12' => 'PSR 12', - 'laravel' => 'Laravel', - 'symfony' => 'Symfony', - 'empty' => 'Empty', - ]; + protected function getPresets(): array + { + $presetManifest = resolve(PresetManifest::class); + $presets = []; + + foreach ($presetManifest->names() as $preset) { + $presets[$preset] = Str::headline($preset); + } + + return [...$presets, 'per' => 'PER', 'psr12' => 'PSR 12']; + } /** * Creates a new Summary Output instance. @@ -63,7 +69,7 @@ public function handle($summary, $totalFiles) 'totalFiles' => $totalFiles, 'issues' => $issues, 'testing' => $summary->isDryRun(), - 'preset' => $this->presets[$this->config->preset()], + 'preset' => $this->getPresets()[$this->config->preset()] ?? $this->config->preset(), ]), ); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3143218f..1e0a3516 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Services\PresetManifest; use Illuminate\Support\ServiceProvider; use PhpCsFixer\Error\ErrorsManager; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -32,5 +33,13 @@ public function register() $this->app->singleton(EventDispatcher::class, function () { return new EventDispatcher; }); + + $this->app->singleton(PresetManifest::class, function ($app) { + return new PresetManifest( + $app->make('files'), + $app->basePath(), + $app->basePath('bootstrap/cache/pint_presets.php'), + ); + }); } } diff --git a/app/Services/PresetManifest.php b/app/Services/PresetManifest.php new file mode 100644 index 00000000..081e9e8f --- /dev/null +++ b/app/Services/PresetManifest.php @@ -0,0 +1,132 @@ + */ + protected ?array $manifest = null; + + protected string $vendorPath; + + public function __construct( + protected Filesystem $files, + protected string $basePath, + protected string $manifestPath, + ) { + $this->vendorPath = $this->basePath.'/vendor'; + } + + /** + * Get all available presets from packages. + * + * @return array ['preset-name' => '/absolute/path/to/preset.php'] + */ + public function presets(): array + { + return $this->manifest ??= $this->getManifest(); + } + + /** + * Check if a preset exists. + */ + public function has(string $preset): bool + { + return array_key_exists($preset, $this->presets()); + } + + /** + * Get the path for a specific preset. + */ + public function path(string $preset): ?string + { + return $this->presets()[$preset] ?? null; + } + + /** + * Get all preset names. + * + * @return list + */ + public function names(): array + { + return array_keys($this->presets()); + } + + /** + * Get the current preset manifest. + * + * @return array + */ + protected function getManifest(): array + { + $path = $this->vendorPath.'/composer/installed.json'; + + if ( + ! $this->files->exists($this->manifestPath) || + $this->files->lastModified($path) > $this->files->lastModified($this->manifestPath) + ) { + return $this->build(); + } + + return $this->files->getRequire($this->manifestPath); + } + + /** + * Build the manifest and write it to disk. + * + * @return array + */ + protected function build(): array + { + $packages = []; + $installedPath = $this->vendorPath.'/composer/installed.json'; + $composerPath = $this->basePath.'/composer.json'; + + if ($this->files->exists($installedPath)) { + $installed = json_decode($this->files->get($installedPath), true); + $packages = $installed['packages'] ?? $installed; + } + + $presets = (new Collection($packages)) + ->keyBy(fn ($package) => $this->vendorPath.'/'.$package['name']) + ->when($this->files->exists($composerPath), function ($presets) use ($composerPath) { + $composer = json_decode($this->files->get($composerPath), true); + + return $presets->put($this->basePath, $composer); + }) + ->map(fn ($package) => $package['extra']['laravel-pint']['presets'] ?? []) + ->flatMap(function (array $presets, string $basePath): array { + foreach ($presets as $name => $relativePath) { + $absolutePath = $basePath.'/'.$relativePath; + + if ($this->files->exists($absolutePath)) { + $presets[$name] = $absolutePath; + } else { + unset($presets[$name]); + } + } + + return $presets; + }) + ->all(); + + $this->write($presets); + + return $presets; + } + + /** + * Write the given manifest array to disk. + * + * @param array $manifest + */ + protected function write(array $manifest): void + { + $this->files->ensureDirectoryExists(dirname($this->manifestPath), 0755, true); + $this->files->replace($this->manifestPath, 'has('laravel'))->toBeTrue(); + expect($presetManifest->path('laravel'))->toContain('resources/presets/laravel.php'); + + expect($presetManifest->has('nonexistent'))->toBeFalse(); + expect($presetManifest->path('nonexistent'))->toBeNull(); +}); + +it('can list available presets', function () { + $this->artisan('preset:list') + ->expectsOutputToContain('laravel') + ->expectsOutputToContain('per') + ->expectsOutputToContain('psr12') + ->expectsOutputToContain('symfony') + ->expectsOutputToContain('empty') + ->assertSuccessful(); +}); diff --git a/tests/Unit/Services/PresetManifestTest.php b/tests/Unit/Services/PresetManifestTest.php new file mode 100644 index 00000000..86642b1b --- /dev/null +++ b/tests/Unit/Services/PresetManifestTest.php @@ -0,0 +1,96 @@ +shouldReceive('exists') + ->with('/test/path/bootstrap/cache/pint_presets.php') + ->andReturn(false); + + $files->shouldReceive('exists') + ->with('/test/path/vendor/composer/installed.json') + ->andReturn(true); + + $files->shouldReceive('lastModified') + ->with('/test/path/vendor/composer/installed.json') + ->andReturn(time()); + + $files->shouldReceive('get') + ->with('/test/path/vendor/composer/installed.json') + ->andReturn(json_encode([ + 'packages' => [ + [ + 'name' => 'acme/pint-presets', + 'extra' => [ + 'laravel-pint' => [ + 'presets' => [ + 'acme' => 'src/presets/acme.php', + 'acme-strict' => 'src/presets/strict.php', + ], + ], + ], + ], + ], + ])); + + $files->shouldReceive('exists') + ->with('/test/path/composer.json') + ->andReturn(false); + + $files->shouldReceive('exists') + ->with('/test/path/vendor/acme/pint-presets/src/presets/acme.php') + ->andReturn(true); + + $files->shouldReceive('exists') + ->with('/test/path/vendor/acme/pint-presets/src/presets/strict.php') + ->andReturn(true); + + $files->shouldReceive('ensureDirectoryExists') + ->with('/test/path/bootstrap/cache', 0755, true); + + $files->shouldReceive('replace') + ->with('/test/path/bootstrap/cache/pint_presets.php', Mockery::type('string')); + + $manifest = new PresetManifest( + $files, + '/test/path', + '/test/path/bootstrap/cache/pint_presets.php', + ); + + expect($manifest->has('acme'))->toBeTrue(); + expect($manifest->has('acme-strict'))->toBeTrue(); + expect($manifest->path('acme'))->toBe('/test/path/vendor/acme/pint-presets/src/presets/acme.php'); +}); + +it('handles missing composer installed.json gracefully', function () { + $files = Mockery::mock(Filesystem::class); + + $files->shouldReceive('exists') + ->with('/test/path/bootstrap/cache/pint_presets.php') + ->andReturn(false); + + $files->shouldReceive('exists') + ->with('/test/path/vendor/composer/installed.json') + ->andReturn(false); + + $files->shouldReceive('exists') + ->with('/test/path/composer.json') + ->andReturn(false); + + $files->shouldReceive('ensureDirectoryExists') + ->with('/test/path/bootstrap/cache', 0755, true); + + $files->shouldReceive('replace') + ->with('/test/path/bootstrap/cache/pint_presets.php', "names())->toBeEmpty(); +});