Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add command to dump required PHP extensions based on vendor/composer/… #599

Merged
merged 5 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 5 additions & 5 deletions src/SPC/command/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,24 +154,24 @@ protected function logWithResult(bool $result, string $success_msg, string $fail
/**
* Parse extension list from string, replace alias and filter internal extensions.
*
* @param string $ext_list Extension string list, e.g. "mbstring,posix,sockets"
* @param array|string $ext_list Extension string list, e.g. "mbstring,posix,sockets" or array
*/
protected function parseExtensionList(string $ext_list): array
protected function parseExtensionList(array|string $ext_list): array
{
// replace alias
$ls = array_map(function ($x) {
$lower = strtolower(trim($x));
if (isset(SPC_EXTENSION_ALIAS[$lower])) {
logger()->notice("Extension [{$lower}] is an alias of [" . SPC_EXTENSION_ALIAS[$lower] . '], it will be replaced.');
logger()->debug("Extension [{$lower}] is an alias of [" . SPC_EXTENSION_ALIAS[$lower] . '], it will be replaced.');
return SPC_EXTENSION_ALIAS[$lower];
}
return $lower;
}, explode(',', $ext_list));
}, is_array($ext_list) ? $ext_list : explode(',', $ext_list));

// filter internals
return array_values(array_filter($ls, function ($x) {
if (in_array($x, SPC_INTERNAL_EXTENSIONS)) {
logger()->warning("Extension [{$x}] is an builtin extension, it will be ignored.");
logger()->debug("Extension [{$x}] is an builtin extension, it will be ignored.");
return false;
}
return true;
Expand Down
160 changes: 125 additions & 35 deletions src/SPC/command/DumpExtensionsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,67 +4,157 @@

namespace SPC\command;

use SPC\store\FileSystem;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;

#[AsCommand(name: 'dump-extensions', description: 'Determines the required php extensions')]
class DumpExtensionsCommand extends BaseCommand
{
private array $files = [
'vendor/composer/installed.json',
'composer.lock',
'composer.json',
];
protected bool $no_motd = true;

public function handle(): int
public function configure(): void
{
$fs = new Filesystem();
$extensions = [];
// path to project files or specific composer file
$this->addArgument('path', InputArgument::OPTIONAL, 'Path to project root', '.');
$this->addOption('format', 'F', InputOption::VALUE_REQUIRED, 'Parsed output format', 'default');
// output zero extension replacement rather than exit as failure
$this->addOption('no-ext-output', 'N', InputOption::VALUE_REQUIRED, 'When no extensions found, output default combination (comma separated)');
// no dev
$this->addOption('no-dev', null, null, 'Do not include dev dependencies');
// no spc filter
$this->addOption('no-spc-filter', 'S', null, 'Do not use SPC filter to determine the required extensions');
}

foreach ($this->files as $file) {
if ($fs->exists($file)) {
$this->output->writeln("<info>Analyzing file: {$file}</info>");
$data = json_decode(file_get_contents($file), true);
public function handle(): int
{
$path = FileSystem::convertPath($this->getArgument('path'));

if (!$data) {
$this->output->writeln("<error>Error parsing {$file}</error>");
continue;
}
$path_installed = FileSystem::convertPath(rtrim($path, '/\\') . '/vendor/composer/installed.json');
$path_lock = FileSystem::convertPath(rtrim($path, '/\\') . '/composer.lock');

$extensions = array_merge($extensions, $this->extractExtensions($data));
$ext_installed = $this->extractFromInstalledJson($path_installed, !$this->getOption('no-dev'));
if ($ext_installed === null) {
if ($this->getOption('format') === 'default') {
$this->output->writeln('<comment>vendor/composer/installed.json load failed, skipped</comment>');
}
$ext_installed = [];
}

if (empty($extensions)) {
$this->output->writeln('<comment>No extensions found.</comment>');
return static::SUCCESS;
$ext_lock = $this->extractFromComposerLock($path_lock, !$this->getOption('no-dev'));
if ($ext_lock === null) {
$this->output->writeln('<error>composer.lock load failed</error>');
return static::FAILURE;
}

$extensions = array_unique($extensions);
$extensions = array_unique(array_merge($ext_installed, $ext_lock));
sort($extensions);

$this->output->writeln("\n<info>Required PHP extensions:</info>");
$this->output->writeln(implode(',', array_map(fn ($ext) => substr($ext, 4), $extensions)));
if (empty($extensions)) {
if ($this->getOption('no-ext-output')) {
$this->outputExtensions(explode(',', $this->getOption('no-ext-output')));
return static::SUCCESS;
}
$this->output->writeln('<error>No extensions found</error>');
return static::FAILURE;
}

$this->outputExtensions($extensions);
return static::SUCCESS;
}

private function extractExtensions(array $data): array
private function filterExtensions(array $requirements): array
{
return array_merge(
...array_map(
function ($package) {
return isset($package['require']) ? $this->filterExtensions($package['require']) : [];
},
$data['packages'] ?? [$data]
return array_map(
fn ($key) => substr($key, 4),
array_keys(
array_filter($requirements, function ($key) {
return str_starts_with($key, 'ext-');
}, ARRAY_FILTER_USE_KEY)
)
);
}

private function filterExtensions(array $requirements): array
private function loadJson(string $file): array|bool
{
return array_keys(array_filter($requirements, function ($key) {
return str_starts_with($key, 'ext-');
}, ARRAY_FILTER_USE_KEY));
if (!file_exists($file)) {
return false;
}

$data = json_decode(file_get_contents($file), true);
if (!$data) {
return false;
}
return $data;
}

private function extractFromInstalledJson(string $file, bool $include_dev = true): ?array
{
if (!($data = $this->loadJson($file))) {
return null;
}

$packages = $data['packages'] ?? [];

if (!$include_dev) {
$packages = array_filter($packages, fn ($package) => !in_array($package['name'], $data['dev-package-names'] ?? []));
}

return array_merge(
...array_map(fn ($x) => isset($x['require']) ? $this->filterExtensions($x['require']) : [], $packages)
);
}

private function extractFromComposerLock(string $file, bool $include_dev = true): ?array
{
if (!($data = $this->loadJson($file))) {
return null;
}

// get packages ext
$packages = $data['packages'] ?? [];
$exts = array_merge(
...array_map(fn ($package) => $this->filterExtensions($package['require'] ?? []), $packages)
);

// get dev packages ext
if ($include_dev) {
$packages = $data['packages-dev'] ?? [];
$exts = array_merge(
$exts,
...array_map(fn ($package) => $this->filterExtensions($package['require'] ?? []), $packages)
);
}

// get require ext
$platform = $data['platform'] ?? [];
$exts = array_merge($exts, $this->filterExtensions($platform));

// get require-dev ext
if ($include_dev) {
$platform = $data['platform-dev'] ?? [];
$exts = array_merge($exts, $this->filterExtensions($platform));
}

return $exts;
}

private function outputExtensions(array $extensions): void
{
if (!$this->getOption('no-spc-filter')) {
$extensions = $this->parseExtensionList($extensions);
}
switch ($this->getOption('format')) {
case 'json':
$this->output->writeln(json_encode($extensions, JSON_PRETTY_PRINT));
break;
case 'text':
$this->output->writeln(implode(',', $extensions));
break;
default:
$this->output->writeln('<info>Required PHP extensions' . ($this->getOption('no-dev') ? ' (without dev)' : '') . ':</info>');
$this->output->writeln(implode(',', $extensions));
}
}
}