feat(installer): add libphpx.so auto-build support and improve PHP detection

- Replace ExtensionProvider::Keyword constant with direct '*' string usage
- Add LibPhpxInstaller class to automatically build libphpx.so from source
- Implement PHPX build dependency installation via system package managers
- Update installer documentation to include libphpx.so auto-build instructions
- Modify PHP directory detection to prioritize running PHP installation
- Adjust CompilerBase to use Composer InstalledVersions for PHPX path resolution
- Refactor LinuxPackageManager to separate build tool packages from configure options
- Add comprehensive tests for LibPhpxInstaller shared library detection
- Update platform warnings to guide users toward interactive auto-build
- Revise ExtensionProvider parsing to handle
pull/18/head
韩天峰 1 month ago
parent c7bdfd883d
commit bc8e4a685b
  1. 20
      docs/LIBPHP_INSTALLER.md
  2. 29
      phpunit/src/Installer/LibPhpxInstallerTest.php
  3. 13
      phpunit/src/Installer/PhpBuildConfigurationTest.php
  4. 13
      src/Build/SourcePipelineTrait.php
  5. 13
      src/CompilerBase.php
  6. 6
      src/Installer/LibPhpInstaller.php
  7. 124
      src/Installer/LibPhpxInstaller.php
  8. 17
      src/Installer/LinuxPackageManager.php
  9. 2
      src/Parser/MethodCallTrait.php
  10. 4
      src/Platform/PlatformBase.php
  11. 9
      src/Platform/UnixPlatform.php
  12. 8
      src/Preprocessor.php
  13. 2
      src/polyfills.php
  14. 2
      tests/compiler/keyword_extension/001.phpt
  15. 2
      tests/compiler/keyword_extension/camel.phpt

@ -1,6 +1,8 @@
# 自动构建 libphp.so # 自动构建 libphp.so
TypePHP 的可执行文件和共享库模式需要 PHP Embed SAPI 提供的 `libphp.so`。许多 Linux 发行版的 PHP 包只包含 CLI 或 FPM,因此 `tpc.php` 在找不到 `libphp.so` 时会询问是否自动构建一份私有 PHP。 TypePHP 的可执行文件和共享库模式需要 PHP Embed SAPI 提供的 `libphp.so`。许多 Linux 发行版的 PHP 包只包含 CLI 或 FPM,因此 Composer 安装的 `tpc.php` 在找不到 `libphp.so` 时会询问是否自动构建一份私有 PHP。
该安装器面向由 PHP 解释器启动的 `tpc.php`。自举二进制 `tpc` 在进入 `main()` 前就必须由系统动态链接器加载 `libphp.so``libphpx.so`,不能依靠自身补装缺失库。
该功能只在 Linux 和交互式终端中启用。扩展模式(`-m ext`)不需要 `libphp.so`,不会触发安装器;CI 等非交互环境也不会自动下载、安装软件包或执行 `sudo` 该功能只在 Linux 和交互式终端中启用。扩展模式(`-m ext`)不需要 `libphp.so`,不会触发安装器;CI 等非交互环境也不会自动下载、安装软件包或执行 `sudo`
@ -15,7 +17,7 @@ vendor/bin/tpc.php project.yml
缺少 `libphp.so` 时,安装器会依次询问: 缺少 `libphp.so` 时,安装器会依次询问:
1. 是否自动构建 PHP Embed 库; 1. 是否自动构建 PHP Embed 库;
2. PHP 版本,默认从 PHP.net 获取最新稳定版 PHP 8.4,也可输入 PHP 8.4/8.5 的具体稳定版本; 2. PHP 版本,默认与当前运行 `tpc.php``PHP_VERSION` 完全一致,也可手动输入其他 PHP 8.4.x/8.5.x 稳定版本;
3. 安装目录,默认为 `~/.typephp` 3. 安装目录,默认为 `~/.typephp`
4. 是否通过检测到的 `apt-get`、`dnf` 或 `yum` 安装缺失的开发包。 4. 是否通过检测到的 `apt-get`、`dnf` 或 `yum` 安装缺失的开发包。
@ -49,3 +51,17 @@ vendor/bin/tpc.php project.yml
```bash ```bash
PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml PHP_HOME=/path/to/php vendor/bin/tpc.php project.yml
``` ```
## 自动构建 libphpx.so
Linux 下完成 PHP Embed 检查后,构建流水线还会检查 PHPX 根目录中的 `lib/libphpx.so`。PHPX 根目录按以下顺序解析:
1. `PHPX_HOME`
2. Composer `InstalledVersions` 中的 `swoole/phpx` 安装路径;
3. TypePHP 源码仓库内的 `vendor/swoole/phpx`
缺少共享库时,交互式终端会询问是否构建。PHPX 本身不依赖 `libphp.so`;它依赖 PHP 头文件和 `php-config`。`LibPhpxInstaller` 使用当前选定的 PHP 前缀,同时设置 `PHP_HOME`、将该前缀的 `bin` 放到 `PATH` 首位,并向 PHPX CMake 传递 `-Dphp_dir=<PHP prefix>`,确保 PHPX 与项目运行时使用的 PHP ABI 一致。
工具链检测在本地库检查之后执行。用户确认自动构建后,安装器通过 `apt-get`、`dnf` 或 `yum` 检查并可安装 GCC/G++、make、CMake、pkg-config 以及 PHP configure 所需依赖;因此 Composer 环境无需预先安装完整 C/C++ 工具链。
构建固定使用 Release、关闭 PHPX tests,并只构建 `phpx` target;并行度最多为 8。输出必须为 `<PHPX>/lib/libphpx.so`,否则安装器报错。非交互环境只提示缺失,不执行 CMake。

@ -0,0 +1,29 @@
<?php
namespace TypePhp\Tests\Installer;
use PHPUnit\Framework\TestCase;
use TypePhp\Installer\LibPhpxInstaller;
final class LibPhpxInstallerTest extends TestCase
{
public function testDetectsSharedLibraryOnlyInPhpxLibDirectory(): void
{
$root = sys_get_temp_dir() . '/typephp-phpx-' . bin2hex(random_bytes(6));
mkdir($root . '/lib', 0755, true);
$installer = new LibPhpxInstaller();
try {
self::assertFalse($installer->hasLibPhpx($root));
touch($root . '/lib/libphpx.a');
self::assertFalse($installer->hasLibPhpx($root));
touch($root . '/lib/libphpx.so');
self::assertTrue($installer->hasLibPhpx($root . '/'));
} finally {
@unlink($root . '/lib/libphpx.so');
@unlink($root . '/lib/libphpx.a');
@rmdir($root . '/lib');
@rmdir($root);
}
}
}

@ -37,11 +37,24 @@ final class PhpBuildConfigurationTest extends TestCase
$packages = $manager->packagesForConfigureOptions(['--with-curl', '--enable-mbstring']); $packages = $manager->packagesForConfigureOptions(['--with-curl', '--enable-mbstring']);
self::assertContains('build-essential', $packages); self::assertContains('build-essential', $packages);
self::assertContains('cmake', $packages);
self::assertContains('libcurl4-openssl-dev', $packages); self::assertContains('libcurl4-openssl-dev', $packages);
self::assertContains('libonig-dev', $packages); self::assertContains('libonig-dev', $packages);
self::assertNotContains('libzip-dev', $packages); self::assertNotContains('libzip-dev', $packages);
} }
public function testBuildToolPackagesIncludeCompilerMakeAndCmake(): void
{
self::assertSame(
['build-essential', 'cmake', 'pkg-config'],
(new LinuxPackageManager('apt-get'))->packagesForBuildTools()
);
self::assertSame(
['gcc', 'gcc-c++', 'make', 'cmake', 'pkgconf-pkg-config'],
(new LinuxPackageManager('dnf'))->packagesForBuildTools()
);
}
public function testMissingPackagesFiltersInstalledPackages(): void public function testMissingPackagesFiltersInstalledPackages(): void
{ {
$manager = new LinuxPackageManager('apt-get'); $manager = new LinuxPackageManager('apt-get');

@ -12,6 +12,7 @@ use TypePhp\Backend\CompilerFactory;
use TypePhp\Exception\SyntaxError; use TypePhp\Exception\SyntaxError;
use TypePhp\Exception\Unsupported; use TypePhp\Exception\Unsupported;
use TypePhp\Installer\LibPhpInstaller; use TypePhp\Installer\LibPhpInstaller;
use TypePhp\Installer\LibPhpxInstaller;
use TypePhp\Platform\Linux; use TypePhp\Platform\Linux;
trait SourcePipelineTrait trait SourcePipelineTrait
@ -64,10 +65,20 @@ trait SourcePipelineTrait
if ($this->isBuildModeEmbed() && $this->getPlatform() instanceof Linux) { if ($this->isBuildModeEmbed() && $this->getPlatform() instanceof Linux) {
try { try {
(new LibPhpInstaller())->ensure($this->getPhpDir()); $phpDir = (new LibPhpInstaller())->ensure($this->getPhpDir()) ?? $this->getPhpDir();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error('Unable to install libphp.so: ' . $e->getMessage()); $this->error('Unable to install libphp.so: ' . $e->getMessage());
} }
} else {
$phpDir = $this->getPhpDir();
}
if ($this->getPlatform() instanceof Linux) {
try {
(new LibPhpxInstaller())->ensure($this->getPhpxDir(), $phpDir);
} catch (\Throwable $e) {
$this->error('Unable to build libphpx.so: ' . $e->getMessage());
}
} }
$this->validateCompilerToolchain(); $this->validateCompilerToolchain();

@ -486,7 +486,18 @@ class CompilerBase implements PropertyAccessContext
return rtrim($phpxDir, '\/'); return rtrim($phpxDir, '\/');
} }
// 尝试使用 Composer 安装的 phpx // Composer-installed TypePHP and PHPX are sibling packages. Ask
// Composer for the actual package path instead of assuming a nested
// vendor directory inside the TypePHP package.
if (class_exists(\Composer\InstalledVersions::class)
&& \Composer\InstalledVersions::isInstalled('swoole/phpx')) {
$composerInstallPath = \Composer\InstalledVersions::getInstallPath('swoole/phpx');
if (is_string($composerInstallPath) && is_dir($composerInstallPath)) {
return rtrim($composerInstallPath, '\/');
}
}
// TypePHP source checkout: phpx is installed below this repository.
$composerPhpxDir = $this->rootPath . self::PHPX_VENDOR_DIR; $composerPhpxDir = $this->rootPath . self::PHPX_VENDOR_DIR;
if (is_dir($composerPhpxDir)) { if (is_dir($composerPhpxDir)) {
return $composerPhpxDir; return $composerPhpxDir;

@ -27,14 +27,12 @@ final class LibPhpInstaller
return null; return null;
} }
$release = $this->latestRelease(); $defaultVersion = PHP_VERSION;
$version = $this->console->ask("PHP version [{$release['version']}]: ", $release['version']); $version = $this->console->ask("PHP version [{$defaultVersion}]: ", $defaultVersion);
if (!preg_match('/^8\.[45]\.\d+$/', $version)) { if (!preg_match('/^8\.[45]\.\d+$/', $version)) {
throw new \RuntimeException('Only stable PHP 8.4.x and 8.5.x versions are supported by the automatic installer'); throw new \RuntimeException('Only stable PHP 8.4.x and 8.5.x versions are supported by the automatic installer');
} }
if ($version !== $release['version']) {
$release = $this->release($version); $release = $this->release($version);
}
$home = getenv('HOME') ?: (string) ($_SERVER['HOME'] ?? ''); $home = getenv('HOME') ?: (string) ($_SERVER['HOME'] ?? '');
$defaultPrefix = rtrim($home, '/') . '/.typephp'; $defaultPrefix = rtrim($home, '/') . '/.typephp';

@ -0,0 +1,124 @@
<?php
namespace TypePhp\Installer;
final class LibPhpxInstaller
{
public function __construct(private readonly InteractiveConsole $console = new InteractiveConsole())
{
}
public function ensure(string $phpxDir, string $phpDir): ?string
{
$phpxDir = rtrim($phpxDir, '/');
$phpDir = rtrim($phpDir, '/');
if (PHP_OS_FAMILY !== 'Linux' || $this->hasLibPhpx($phpxDir)) {
return $phpxDir;
}
if (!$this->console->isInteractive()) {
$this->console->write('libphpx.so is missing. Run tpc.php in an interactive terminal to build it automatically, or set PHPX_HOME.');
return null;
}
$this->validateSourceTree($phpxDir);
$this->validatePhpInstallation($phpDir);
$this->console->write("The PHPX installation does not provide libphpx.so: {$phpxDir}");
if (!$this->console->confirm('Build libphpx.so now?', true)) {
return null;
}
$this->installBuildDependencies($phpDir);
$buildDir = $phpxDir . '/build';
$this->mkdir($buildDir);
$this->run([
'cmake',
'-S', $phpxDir,
'-B', $buildDir,
'-DCMAKE_BUILD_TYPE=Release',
'-DBUILD_TESTS=OFF',
'-Dphp_dir=' . $phpDir,
], $phpDir);
$this->run([
'cmake',
'--build', $buildDir,
'--parallel', (string) min(8, max(1, $this->cpuCount())),
'--target', 'phpx',
], $phpDir);
if (!$this->hasLibPhpx($phpxDir)) {
throw new \RuntimeException("Build completed but {$phpxDir}/lib/libphpx.so was not created");
}
$this->console->write("libphpx.so built successfully in {$phpxDir}/lib");
return $phpxDir;
}
public function hasLibPhpx(string $phpxDir): bool
{
return is_file(rtrim($phpxDir, '/') . '/lib/libphpx.so');
}
private function validateSourceTree(string $phpxDir): void
{
if (!is_file($phpxDir . '/CMakeLists.txt') || !is_dir($phpxDir . '/src') || !is_dir($phpxDir . '/include')) {
throw new \RuntimeException("PHPX source tree is incomplete: {$phpxDir}");
}
}
private function validatePhpInstallation(string $phpDir): void
{
$php = $phpDir . '/bin/php';
$phpConfig = $phpDir . '/bin/php-config';
if (!is_executable($php) || !is_executable($phpConfig)) {
throw new \RuntimeException("PHP installation must provide bin/php and bin/php-config: {$phpDir}");
}
}
private function installBuildDependencies(string $phpDir): void
{
$manager = LinuxPackageManager::detect();
if ($manager === null) {
$this->console->write('No supported package manager (apt-get/dnf/yum) was found; continuing with existing build tools.');
return;
}
$packages = $manager->missingPackages($manager->packagesForBuildTools());
$this->console->write('Detected package manager: ' . $manager->command);
if ($packages === []) {
$this->console->write('All detected PHPX build dependencies are already installed.');
return;
}
if (!$this->console->confirm('Install missing PHPX build dependencies (' . implode(', ', $packages) . ')?', true)) {
return;
}
$useSudo = function_exists('posix_geteuid') && posix_geteuid() !== 0;
$refresh = $manager->refreshCommand($useSudo);
if ($refresh !== null) {
$this->run($refresh, $phpDir);
}
$this->run($manager->installCommand($packages, $useSudo), $phpDir);
}
private function run(array $command, string $phpDir): void
{
$this->console->write('$ ' . implode(' ', array_map('escapeshellarg', $command)));
$environment = getenv();
$environment['PHP_HOME'] = $phpDir;
$environment['PATH'] = $phpDir . '/bin:' . ($environment['PATH'] ?? '');
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, null, $environment);
if (!is_resource($process) || proc_close($process) !== 0) {
throw new \RuntimeException('Command failed: ' . implode(' ', $command));
}
}
private function mkdir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
throw new \RuntimeException("Unable to create directory {$directory}");
}
}
private function cpuCount(): int
{
$count = (int) trim((string) shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null'));
return $count > 0 ? $count : 1;
}
}

@ -29,9 +29,9 @@ final readonly class LinuxPackageManager
$text = implode(' ', $options); $text = implode(' ', $options);
$groups = [ $groups = [
'base' => [ 'base' => [
'apt-get' => ['build-essential', 'pkg-config', 'autoconf', 'bison', 're2c', 'libxml2-dev', 'libsqlite3-dev'], 'apt-get' => ['autoconf', 'bison', 're2c', 'libxml2-dev', 'libsqlite3-dev'],
'dnf' => ['gcc', 'gcc-c++', 'make', 'pkgconf-pkg-config', 'autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], 'dnf' => ['autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'],
'yum' => ['gcc', 'gcc-c++', 'make', 'pkgconfig', 'autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'], 'yum' => ['autoconf', 'bison', 're2c', 'libxml2-devel', 'sqlite-devel'],
], ],
'curl' => ['needle' => '--with-curl', 'apt-get' => ['libcurl4-openssl-dev'], 'dnf' => ['libcurl-devel'], 'yum' => ['libcurl-devel']], 'curl' => ['needle' => '--with-curl', 'apt-get' => ['libcurl4-openssl-dev'], 'dnf' => ['libcurl-devel'], 'yum' => ['libcurl-devel']],
'openssl' => ['needle' => '--with-openssl', 'apt-get' => ['libssl-dev'], 'dnf' => ['openssl-devel'], 'yum' => ['openssl-devel']], 'openssl' => ['needle' => '--with-openssl', 'apt-get' => ['libssl-dev'], 'dnf' => ['openssl-devel'], 'yum' => ['openssl-devel']],
@ -52,7 +52,7 @@ final readonly class LinuxPackageManager
'gd' => ['needle' => '--enable-gd', 'apt-get' => ['libpng-dev', 'libjpeg-dev', 'libwebp-dev', 'libfreetype6-dev'], 'dnf' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel'], 'yum' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel']], 'gd' => ['needle' => '--enable-gd', 'apt-get' => ['libpng-dev', 'libjpeg-dev', 'libwebp-dev', 'libfreetype6-dev'], 'dnf' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel'], 'yum' => ['libpng-devel', 'libjpeg-turbo-devel', 'libwebp-devel', 'freetype-devel']],
]; ];
$packages = $groups['base'][$this->command]; $packages = [...$this->packagesForBuildTools(), ...$groups['base'][$this->command]];
unset($groups['base']); unset($groups['base']);
foreach ($groups as $group) { foreach ($groups as $group) {
if (str_contains($text, $group['needle'])) { if (str_contains($text, $group['needle'])) {
@ -62,6 +62,15 @@ final readonly class LinuxPackageManager
return array_values(array_unique($packages)); return array_values(array_unique($packages));
} }
public function packagesForBuildTools(): array
{
return match ($this->command) {
'apt-get' => ['build-essential', 'cmake', 'pkg-config'],
'dnf' => ['gcc', 'gcc-c++', 'make', 'cmake', 'pkgconf-pkg-config'],
'yum' => ['gcc', 'gcc-c++', 'make', 'cmake', 'pkgconfig'],
};
}
public function installCommand(array $packages, bool $useSudo): array public function installCommand(array $packages, bool $useSudo): array
{ {
$prefix = $useSudo ? ['sudo'] : []; $prefix = $useSudo ? ['sudo'] : [];

@ -336,7 +336,7 @@ trait MethodCallTrait
return $this->parseUniversalMethodCall($expr, $object, $methodName, $anyExtension, $this->isVarExpr($expr->var)); return $this->parseUniversalMethodCall($expr, $object, $methodName, $anyExtension, $this->isVarExpr($expr->var));
} }
} }
// ExtensionProvider::Keyword extensions apply to every receiver type. // ExtensionProvider('*') extensions apply to every receiver type.
$kwExt = $this->findKeywordExtensionMethod($methodName); $kwExt = $this->findKeywordExtensionMethod($methodName);
if ($kwExt) { if ($kwExt) {
return $this->parseUniversalMethodCall($expr, $object, $methodName, $kwExt, $this->isVarExpr($expr->var)); return $this->parseUniversalMethodCall($expr, $object, $methodName, $kwExt, $this->isVarExpr($expr->var));

@ -103,14 +103,14 @@ abstract class PlatformBase
if (!is_file($phpDir . '/lib/libphp.' . $ext)) { if (!is_file($phpDir . '/lib/libphp.' . $ext)) {
$warnings[] = [ $warnings[] = [
'warning' => "The `libphp.{$ext}` is not found", 'warning' => "The `libphp.{$ext}` is not found",
'info' => 'Note: If you are building an extension (-m ext), this is OK. For binary mode, please run `make` to build it', 'info' => 'Run tpc.php in an interactive terminal to build it automatically, or set PHP_HOME',
]; ];
} }
if (!is_file($phpxDir . '/lib/libphpx.' . $ext)) { if (!is_file($phpxDir . '/lib/libphpx.' . $ext)) {
$warnings[] = [ $warnings[] = [
'warning' => "The `libphpx.{$ext}` is not found", 'warning' => "The `libphpx.{$ext}` is not found",
'info' => 'Note: If you are building an extension (-m ext), this is OK. For binary mode, please run `make` to build it', 'info' => 'Run tpc.php in an interactive terminal to build it automatically, or set PHPX_HOME',
]; ];
} }

@ -88,6 +88,15 @@ abstract class UnixPlatform extends PlatformBase
return rtrim($phpDir, '\/'); return rtrim($phpDir, '\/');
} }
// Composer executes tpc.php with an already selected PHP binary. Use
// that installation before consulting an unrelated php-config from
// PATH, which may point at another PHP minor version or ABI.
$runningPhp = realpath(PHP_BINARY) ?: PHP_BINARY;
$runningPhpDir = dirname(dirname($runningPhp));
if (is_executable($runningPhpDir . '/bin/php-config')) {
return $runningPhpDir;
}
$phpDir = shell_exec('php-config --prefix 2>/dev/null'); $phpDir = shell_exec('php-config --prefix 2>/dev/null');
if (!empty($phpDir)) { if (!empty($phpDir)) {
return trim($phpDir); return trim($phpDir);

@ -634,6 +634,9 @@ class Preprocessor extends CompilerBase
private function parseExtensionProviderTargetValue(Node\Expr $value, NodeAbstract $errorNode): string private function parseExtensionProviderTargetValue(Node\Expr $value, NodeAbstract $errorNode): string
{ {
if ($value instanceof Node\Scalar\String_ && $value->value === '*') {
return '*';
}
if ($value instanceof Node\Expr\ClassConstFetch if ($value instanceof Node\Expr\ClassConstFetch
&& $this->isNameExpr($value->class) && $this->isNameExpr($value->class)
&& $this->isIdExpr($value->name)) { && $this->isIdExpr($value->name)) {
@ -659,11 +662,8 @@ class Preprocessor extends CompilerBase
if (strcasecmp($class, 'Type') === 0 && isset($targets[$constant])) { if (strcasecmp($class, 'Type') === 0 && isset($targets[$constant])) {
return $targets[$constant]; return $targets[$constant];
} }
if (strcasecmp($class, 'ExtensionProvider') === 0 && $constant === 'Keyword') {
return '*';
}
} }
$this->fatalError($errorNode, 'ExtensionProvider target must be Type::*, ExtensionProvider::Keyword, or ClassName::class'); $this->fatalError($errorNode, "ExtensionProvider target must be '*', Type::*, or ClassName::class");
} }
protected function buildLiteralArrayInitPlan(Node\Expr\Array_ $defaultNode): ArrayInitPlan protected function buildLiteralArrayInitPlan(Node\Expr\Array_ $defaultNode): ArrayInitPlan

@ -9,8 +9,6 @@
#[Attribute(Attribute::TARGET_CLASS)] #[Attribute(Attribute::TARGET_CLASS)]
final readonly class ExtensionProvider final readonly class ExtensionProvider
{ {
public const string Keyword = '*';
public function __construct(public string $target) public function __construct(public string $target)
{ {
} }

@ -5,7 +5,7 @@ Keyword ExtensionProvider method with snake_case name
declare(strict_types=1); declare(strict_types=1);
use native_types; use native_types;
#[ExtensionProvider(ExtensionProvider::Keyword)] #[ExtensionProvider('*')]
final class KeywordExtensions final class KeywordExtensions
{ {
public static function var_dump(mixed $var): void public static function var_dump(mixed $var): void

@ -6,7 +6,7 @@ Keyword ExtensionProvider method with lowerCamelCase name
declare(strict_types=1); declare(strict_types=1);
use native_types; use native_types;
#[ExtensionProvider(ExtensionProvider::Keyword)] #[ExtensionProvider('*')]
final class KeywordExtensions final class KeywordExtensions
{ {
public static function inspectValue(mixed $value, string $prefix): void public static function inspectValue(mixed $value, string $prefix): void

Loading…
Cancel
Save