TypePHP 编译器 https://swoole.com/aot/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

219 lines
8.5 KiB

<?php
namespace TypePhp\Config;
use Closure;
use Symfony\Component\Yaml\Yaml;
final class ProjectYamlLoader
{
/** @param Closure(string): never $error */
public function __construct(
private string $phpVersion,
private readonly Closure $error,
private readonly string $osFamily = PHP_OS_FAMILY,
) {
}
public function setPhpVersion(string $phpVersion): void
{
$this->phpVersion = $phpVersion;
}
public function load(string $path): array
{
return $this->loadFile($path, []);
}
/** @param list<string> $stack */
private function loadFile(string $path, array $stack): array
{
$canonicalPath = realpath($path);
if ($canonicalPath === false || !is_file($canonicalPath)) {
($this->error)('Project YAML file does not exist: `' . $path . '`');
}
$cycleAt = array_search($canonicalPath, $stack, true);
if ($cycleAt !== false) {
$cycle = array_slice($stack, $cycleAt);
$cycle[] = $canonicalPath;
($this->error)('Circular project YAML include: ' . implode(' -> ', $cycle));
}
$stack[] = $canonicalPath;
$config = Yaml::parseFile($canonicalPath);
if (!is_array($config)) {
($this->error)('Project YAML root must be a map');
}
$include = $config['include'] ?? [];
unset($config['include']);
if (is_string($include)) {
$include = [$include];
} elseif (!is_array($include) || !array_is_list($include)) {
($this->error)('`include` must be a YAML file path or a list of YAML file paths');
}
$merged = [];
foreach ($include as $includedPath) {
if (!is_string($includedPath) || trim($includedPath) === '') {
($this->error)('Each `include` entry must be a non-empty string');
}
$includedPath = trim($includedPath);
if (!$this->isAbsolutePath($includedPath)) {
$includedPath = dirname($canonicalPath) . DIRECTORY_SEPARATOR . $includedPath;
}
$merged = $this->mergeConfig(
$merged,
$this->loadFile($includedPath, $stack),
);
}
return $this->mergeConfig($merged, $config);
}
private function isAbsolutePath(string $path): bool
{
return $path !== '' && (
$path[0] === '/'
|| $path[0] === '\\'
|| preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1
);
}
private function mergeConfig(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (isset($base[$key])
&& is_array($base[$key])
&& !array_is_list($base[$key])
&& is_array($value)
&& !array_is_list($value)) {
$base[$key] = $this->mergeConfig($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
/** @return array{0: string, 1: string|null} */
public function parseSourceEntry(mixed $entry): array
{
return $this->parsePathEntry($entry, 'sources');
}
/** @return array{0: string, 1: string|null} */
public function parseObjectEntry(mixed $entry): array
{
return $this->parsePathEntry($entry, 'objects');
}
/** @return array{0: string, 1: string|null} */
private function parsePathEntry(mixed $entry, string $key): array
{
if (is_string($entry)) {
return [$entry, null];
}
if (!is_array($entry)) {
($this->error)("Each `{$key}` entry must be a string or map");
}
$path = $entry['path'] ?? $entry['source'] ?? $entry['file'] ?? null;
if (!is_string($path) || trim($path) === '') {
($this->error)("Conditional `{$key}` entries must include a non-empty `path`");
}
$condition = $entry['if'] ?? $entry['when'] ?? null;
if ($condition !== null && !is_string($condition)) {
($this->error)(ucfirst(rtrim($key, 's')) . ' condition must be a string');
}
return [$path, $condition];
}
public function evaluateCondition(string $condition): bool
{
$condition = trim($condition);
if ($condition === '') {
($this->error)('Source condition must not be empty');
}
$expr = $this->replacePhpVersionComparisons($condition);
$expr = $this->replaceOsFamilyComparisons($expr, $condition);
if (preg_match('/[A-Za-z_]/', $expr)
|| !preg_match('/^[0-9\s<>=!&|().+-]+$/', $expr)
|| preg_match('/(?<![&])&(?!&)|(?<![|])\|(?!\|)/', $expr)) {
($this->error)('Unsupported source condition: `' . $condition . '`');
}
try {
return (bool) eval('return (' . $expr . ');');
} catch (\Throwable) {
($this->error)('Invalid source condition: `' . $condition . '`');
}
}
private function replacePhpVersionComparisons(string $condition): string
{
$literal = '"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'';
$operator = '(>=|<=|==|!=|<>|=|>|<|lt|le|gt|ge|eq|ne)';
$patterns = [
'/\bPHP_VERSION_ID\b\s*' . $operator . '\s*([0-9]+)/i' => fn(array $m): bool => version_compare($this->phpVersion, $this->versionIdToString((int) $m[2]), strtolower($m[1])),
'/([0-9]+)\s*' . $operator . '\s*\bPHP_VERSION_ID\b/i' => fn(array $m): bool => version_compare($this->versionIdToString((int) $m[1]), $this->phpVersion, strtolower($m[2])),
'/\bPHP_VERSION\b\s*' . $operator . '\s*(' . $literal . ')/i' => function (array $m): bool {
$version = stripcslashes(($m[3] ?? '') !== '' ? $m[3] : $m[4]);
$this->assertVersion($version);
return version_compare($this->phpVersion, $version, strtolower($m[1]));
},
'/(' . $literal . ')\s*' . $operator . '\s*\bPHP_VERSION\b/i' => function (array $m): bool {
$version = stripcslashes($m[2] !== '' ? $m[2] : $m[3]);
$this->assertVersion($version);
return version_compare($version, $this->phpVersion, strtolower($m[4]));
},
];
$expr = $condition;
foreach ($patterns as $pattern => $compare) {
$expr = preg_replace_callback($pattern, fn(array $m): string => $compare($m) ? '1' : '0', $expr);
if ($expr === null) {
($this->error)('Invalid source condition: `' . $condition . '`');
}
}
if (preg_match('/\bPHP_VERSION(?:_ID)?\b/', $expr)) {
($this->error)('Unsupported source condition: `' . $condition . '`');
}
return $expr;
}
private function replaceOsFamilyComparisons(string $expr, string $condition): string
{
$literal = '"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'';
$expr = preg_replace_callback('/\bPHP_OS_FAMILY\b\s*(==|!=)\s*(' . $literal . ')/i', function (array $m): string {
$expected = stripcslashes(($m[3] ?? '') !== '' ? $m[3] : $m[4]);
$this->assertOsFamily($expected);
return (($this->osFamily === $expected) xor ($m[1] === '!=')) ? '1' : '0';
}, $expr);
$expr = preg_replace_callback('/(' . $literal . ')\s*(==|!=)\s*\bPHP_OS_FAMILY\b/i', function (array $m): string {
$expected = stripcslashes($m[2] !== '' ? $m[2] : $m[3]);
$this->assertOsFamily($expected);
return (($expected === $this->osFamily) xor ($m[4] === '!=')) ? '1' : '0';
}, $expr ?? '');
if ($expr === null || preg_match('/\bPHP_OS_FAMILY\b/', $expr)) {
($this->error)('Unsupported source condition: `' . $condition . '`');
}
return $expr;
}
private function assertVersion(string $version): void
{
if ($version === '' || !preg_match('/^[0-9A-Za-z_.+\-]+$/', $version)) {
($this->error)('Invalid PHP_VERSION literal: `' . $version . '`');
}
}
private function assertOsFamily(string $osFamily): void
{
if (!in_array($osFamily, ['Windows', 'BSD', 'Darwin', 'Solaris', 'Linux', 'Unknown'], true)) {
($this->error)('Invalid PHP_OS_FAMILY literal: `' . $osFamily . '`');
}
}
private function versionIdToString(int $versionId): string
{
return intdiv($versionId, 10000) . '.' . intdiv($versionId % 10000, 100) . '.' . ($versionId % 100);
}
}