From ae365645730b95d5b01b0df7382b91668d5fa24e Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 7 Aug 2026 20:51:59 +0900 Subject: refactor(platform-repository): fetch the PHP runtime in one RPC call The RuntimeInterface seam asked the worker one question at a time: a round trip per loaded extension, per ReflectionExtension::info() output and per constant, so a single `show --platform` cost 70 to 100 of them. A `platform` dispatch entry now answers all of it as one PHP array, which shirabe-php-rpc decodes into a OnceLock-cached PlatformInfo, the way the diagnose command already works. Composer\Platform\Runtime therefore has no Rust counterpart any more. Its work belongs to the running interpreter, and invoke()/construct() could only be ported as a whitelist that panicked on anything unlisted; it is ported as PHP into the worker instead, and PlatformRepository reads the answers off PlatformInfo. Accessors panic on a name the payload does not carry, so the worker and its consumers cannot drift apart unnoticed. The tests describe the runtime as payload data where they used to mock the seam, with the datasets unchanged. The one loss is the call-count assertion of test_inet_pton_regression: the payload reports the result of `@inet_pton('::')` rather than answering a call. Co-Authored-By: Claude Opus 5 (1M context) --- crates/shirabe-php-rpc/php/worker.php | 134 +++++- crates/shirabe-php-rpc/src/lib.rs | 522 ++++++++++++++++----- .../shirabe/src/command/base_dependency_command.rs | 4 +- .../src/command/check_platform_reqs_command.rs | 2 +- crates/shirabe/src/command/completion_trait.rs | 6 +- .../shirabe/src/command/create_project_command.rs | 2 + crates/shirabe/src/command/diagnose_command.rs | 2 +- crates/shirabe/src/command/init_command.rs | 2 +- .../shirabe/src/command/package_discovery_trait.rs | 2 +- crates/shirabe/src/command/require_command.rs | 8 +- crates/shirabe/src/command/search_command.rs | 2 +- crates/shirabe/src/command/show_command.rs | 8 +- crates/shirabe/src/command/suggests_command.rs | 4 + crates/shirabe/src/dependency_resolver/problem.rs | 2 +- crates/shirabe/src/installer.rs | 2 + crates/shirabe/src/platform.rs | 6 +- crates/shirabe/src/platform/runtime.rs | 237 ---------- .../shirabe/src/repository/platform_repository.rs | 251 +++++----- .../tests/package/version/version_selector_test.rs | 8 +- crates/shirabe/tests/platform/main.rs | 1 - crates/shirabe/tests/platform/runtime_test.rs | 28 -- .../tests/repository/platform_repository_test.rs | 308 +++++------- docs/dev/php-rpc.md | 18 +- 23 files changed, 791 insertions(+), 768 deletions(-) delete mode 100644 crates/shirabe/src/platform/runtime.rs delete mode 100644 crates/shirabe/tests/platform/runtime_test.rs diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php index 15b38770..578c30d0 100644 --- a/crates/shirabe-php-rpc/php/worker.php +++ b/crates/shirabe-php-rpc/php/worker.php @@ -455,6 +455,77 @@ ShirabeRpcRuntime::$stubAutoloader = static function (string $class): void { }; spl_autoload_register(ShirabeRpcRuntime::$stubAutoloader, true, true); +/** + * Port of Composer\Platform\Runtime, whose runtime queries feed the `platform` payload. Its + * parseHtmlExtensionInfo() has no counterpart here: the worker is always the CLI SAPI, so + * getExtensionInfo() never takes the branch that reformats phpinfo()'s HTML output. + */ +final class ShirabePlatformRuntime +{ + /** The constants the `platform` payload reports, named ltrim($class.'::'.$constant, ':'). */ + public const CONSTANTS = [ + 'PHP_VERSION', + 'PHP_DEBUG', + 'PHP_ZTS', + 'PHP_INT_SIZE', + 'AF_INET6', + 'GD_VERSION', + 'GMP_VERSION', + 'ICONV_VERSION', + 'INTL_ICU_VERSION', + 'LIBXML_DOTTED_VERSION', + 'MB_ONIGURUMA_VERSION', + 'OPENSSL_VERSION_TEXT', + 'PCRE_VERSION', + 'PGSQL_LIBPQ_VERSION', + 'RD_KAFKA_VERSION', + 'SODIUM_LIBRARY_VERSION', + 'LIBXSLT_DOTTED_VERSION', + 'ZipArchive::LIBZIP_VERSION', + 'ZLIB_VERSION', + ]; + + /** The extensions whose info() output the `platform` payload reports when they are loaded. */ + public const EXTENSION_INFO = [ + 'amqp', + 'bz2', + 'curl', + 'date', + 'fileinfo', + 'gd', + 'intl', + 'ldap', + 'mbstring', + 'memcached', + 'mongodb', + 'mysqlnd', + 'pcre', + 'pdo_mysql', + 'pdo_pgsql', + 'pdo_sqlite', + 'pgsql', + 'pq', + 'sqlite3', + 'ssh2', + 'xsl', + 'yaml', + 'zlib', + ]; + + /** The classes the `platform` payload reports the existence of. */ + public const CLASSES = ['ResourceBundle', 'IntlChar']; + + public static function getExtensionInfo(string $extension): string + { + $reflector = new ReflectionExtension($extension); + + ob_start(); + $reflector->info(); + + return (string) ob_get_clean(); + } +} + // Port of Composer\XdebugHandler\XdebugHandler::setXdebugDetails(), which the diagnose payload // reports as `xdebug_active`. $xdebug_active = static function (): bool { @@ -491,9 +562,6 @@ $xdebug_active = static function (): bool { ShirabeRpcRuntime::$dispatch = [ 'constant' => static fn($args) => defined($args[0]) ? constant($args[0]) : null, - 'inet_pton' => static fn($args) => @inet_pton($args[0]), - 'curl_version' => static fn($args) => function_exists('curl_version') ? (curl_version()['version'] ?? null) : null, - 'get_loaded_extensions' => static fn($args) => get_loaded_extensions(), 'get_all_ini_files' => static function ($args) { $paths = [(string) php_ini_loaded_file()]; $scanned = php_ini_scanned_files(); @@ -502,14 +570,60 @@ ShirabeRpcRuntime::$dispatch = [ } return $paths; }, - 'extension_info' => static function ($args) { - if (!extension_loaded($args[0])) { - return ''; + 'platform' => static function ($args) { + $extensions = get_loaded_extensions(); + + $extension_versions = []; + foreach ($extensions as $extension) { + $version = phpversion($extension); + $extension_versions[$extension] = $version !== false ? $version : '0'; } - $re = new ReflectionExtension($args[0]); - ob_start(); - $re->info(); - return (string) ob_get_clean(); + + $extension_info = []; + foreach (ShirabePlatformRuntime::EXTENSION_INFO as $extension) { + if (in_array($extension, $extensions, true)) { + $extension_info[$extension] = ShirabePlatformRuntime::getExtensionInfo($extension); + } + } + + // Only the defined constants carry a value; `constant_names` tells the Rust side which + // names were looked up, so a name it reads but this list omits is an error rather than a + // silently undefined constant. + $constants = []; + foreach (ShirabePlatformRuntime::CONSTANTS as $constant) { + if (defined($constant)) { + $constants[$constant] = constant($constant); + } + } + + $classes = []; + foreach (ShirabePlatformRuntime::CLASSES as $class) { + $classes[$class] = class_exists($class, false); + } + + // The values below stand in for the PHP objects and calls PlatformRepository reaches + // through Composer\Platform\Runtime::invoke()/construct(), reduced to the entries it reads. + $resource_bundle = null; + if ($classes['ResourceBundle']) { + $bundle = ResourceBundle::create('root', 'ICUDATA', false); + if ($bundle !== null) { + $resource_bundle = ['Version' => $bundle->get('Version')]; + } + } + + return [ + 'extensions' => $extensions, + 'extension_versions' => $extension_versions, + 'extension_info' => $extension_info, + 'constant_names' => ShirabePlatformRuntime::CONSTANTS, + 'constants' => $constants, + 'classes' => $classes, + 'curl_version' => extension_loaded('curl') ? curl_version() : null, + 'inet_pton_ipv6' => @inet_pton('::'), + 'resource_bundle' => $resource_bundle, + 'intl_char_unicode_version' => $classes['IntlChar'] ? IntlChar::getUnicodeVersion() : null, + 'imagick' => extension_loaded('imagick') ? (new Imagick())->getVersion() : null, + ]; }, 'diagnose' => static function ($args) use ($xdebug_active) { $extensions = []; diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index 69790695..e2f5fd1e 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -30,49 +30,11 @@ pub fn get_php_binary() -> String { } } -/// PHP `defined($name)`. -pub fn has_constant(name: &str) -> bool { - match call("defined", name) { - PhpMixed::Bool(b) => b, - other => panic!("PHP RPC: `defined` did not return a bool: {other:?}"), - } -} - /// PHP `constant($name)`. -pub fn get_constant(name: &str) -> PhpMixed { +fn get_constant(name: &str) -> PhpMixed { call("constant", name) } -/// PHP `class_exists($name)`, with autoloading, as the runtime sees it. -pub fn class_exists(name: &str) -> bool { - match call("class_exists", name) { - PhpMixed::Bool(exists) => exists, - other => panic!("PHP RPC: `class_exists` returned an unexpected value: {other:?}"), - } -} - -/// PHP `inet_pton($address)`. -pub fn inet_pton(address: &str) -> PhpMixed { - call("inet_pton", address) -} - -/// PHP `curl_version()['version']`. -pub fn curl_version() -> Option { - match call("curl_version", "") { - PhpMixed::String(s) => Some(s), - PhpMixed::Null => None, - other => panic!("PHP RPC: `curl_version` returned an unexpected value: {other:?}"), - } -} - -/// PHP `(new \ReflectionExtension($name))->info()` output. -pub fn get_extension_info(name: &str) -> String { - match call("extension_info", name) { - PhpMixed::String(s) => s, - other => panic!("PHP RPC: `extension_info` did not return a string: {other:?}"), - } -} - /// `curl_version()`, together with the `CURL_*` constants the `diagnose` command consults. Every /// `Option` field is `None` when the corresponding array key or constant is absent. #[derive(Debug)] @@ -89,7 +51,7 @@ pub struct Curl { } /// Everything the `diagnose` command needs to know about the PHP runtime, fetched in a single -/// round trip because the command would otherwise probe the same runtime dozens of times. +/// round trip because the command would otherwise query the same runtime dozens of times. #[derive(Debug)] pub struct Diagnostics { pub php_version: String, @@ -118,28 +80,28 @@ pub struct Diagnostics { } impl Diagnostics { - /// PHP `extension_loaded($name)`. Only the extensions the worker probes can be asked about; + /// PHP `extension_loaded($name)`. Only the extensions the worker reports can be asked about; /// any other name is a bug in the caller, not a missing extension. pub fn extension_loaded(&self, name: &str) -> bool { *self.extensions.get(name).unwrap_or_else(|| { - panic!("PHP RPC: extension `{name}` is not probed by the diagnose payload") + panic!("PHP RPC: extension `{name}` is not reported by the diagnose payload") }) } - /// PHP `function_exists($name)`. See [`Diagnostics::extension_loaded`] for the fixed probe set. + /// PHP `function_exists($name)`. See [`Diagnostics::extension_loaded`] for the fixed set of names. pub fn function_exists(&self, name: &str) -> bool { *self.functions.get(name).unwrap_or_else(|| { - panic!("PHP RPC: function `{name}` is not probed by the diagnose payload") + panic!("PHP RPC: function `{name}` is not reported by the diagnose payload") }) } /// PHP `ini_get($option)`, with PHP's `false` (no such setting) mapped to `None`. See - /// [`Diagnostics::extension_loaded`] for the fixed probe set. + /// [`Diagnostics::extension_loaded`] for the fixed set of names. pub fn ini_get(&self, option: &str) -> Option<&str> { self.ini_settings .get(option) .unwrap_or_else(|| { - panic!("PHP RPC: ini setting `{option}` is not probed by the diagnose payload") + panic!("PHP RPC: ini setting `{option}` is not reported by the diagnose payload") }) .as_deref() } @@ -155,123 +117,416 @@ pub fn get_diagnostics() -> &'static Diagnostics { let payload = payload .as_array() .unwrap_or_else(|| panic!("PHP RPC: `diagnose` did not return an array: {payload:?}")); + let payload = &Payload { + what: "diagnose", + map: payload, + }; Diagnostics { - php_version: string_field(payload, "php_version"), - php_version_id: int_field(payload, "php_version_id"), - php_binary: nullable_string_field(payload, "php_binary"), - openssl_version_text: nullable_string_field(payload, "openssl_version_text"), - openssl_version_number: int_field(payload, "openssl_version_number"), - has_hhvm_version: bool_field(payload, "has_hhvm_version"), - has_php_windows_version_build: bool_field(payload, "has_php_windows_version_build"), - xdebug_active: bool_field(payload, "xdebug_active"), - ioncube_loader_iversion: int_field(payload, "ioncube_loader_iversion"), - ioncube_loader_version: string_field(payload, "ioncube_loader_version"), - phpinfo_general: string_field(payload, "phpinfo_general"), + php_version: payload.string("php_version"), + php_version_id: payload.int("php_version_id"), + php_binary: payload.nullable_string("php_binary"), + openssl_version_text: payload.nullable_string("openssl_version_text"), + openssl_version_number: payload.int("openssl_version_number"), + has_hhvm_version: payload.bool("has_hhvm_version"), + has_php_windows_version_build: payload.bool("has_php_windows_version_build"), + xdebug_active: payload.bool("xdebug_active"), + ioncube_loader_iversion: payload.int("ioncube_loader_iversion"), + ioncube_loader_version: payload.string("ioncube_loader_version"), + phpinfo_general: payload.string("phpinfo_general"), curl: curl_field(payload, "curl"), - extensions: map_field(payload, "extensions") + extensions: payload.bool_map("extensions"), + functions: payload.bool_map("functions"), + ini_settings: payload + .entries("ini") + .map(|(name, value)| (name.clone(), as_nullable_string(value, "ini", name))) + .collect(), + } + }) +} + +/// The constants the `platform` payload carries, in the worker's order. +const PLATFORM_CONSTANTS: &[&str] = &[ + "PHP_VERSION", + "PHP_DEBUG", + "PHP_ZTS", + "PHP_INT_SIZE", + "AF_INET6", + "GD_VERSION", + "GMP_VERSION", + "ICONV_VERSION", + "INTL_ICU_VERSION", + "LIBXML_DOTTED_VERSION", + "MB_ONIGURUMA_VERSION", + "OPENSSL_VERSION_TEXT", + "PCRE_VERSION", + "PGSQL_LIBPQ_VERSION", + "RD_KAFKA_VERSION", + "SODIUM_LIBRARY_VERSION", + "LIBXSLT_DOTTED_VERSION", + "ZipArchive::LIBZIP_VERSION", + "ZLIB_VERSION", +]; + +/// The classes the `platform` payload carries the existence of. +const PLATFORM_CLASSES: &[&str] = &["ResourceBundle", "IntlChar"]; + +/// Everything `PlatformRepository` needs to know about the PHP runtime, fetched in a single round +/// trip because it would otherwise query the same runtime once per extension and constant. +/// +/// The fields standing in for PHP objects (`resource_bundle`, `imagick`) carry the entries the +/// consumer reads off them rather than the object itself. +#[derive(Debug, Clone)] +pub struct PlatformInfo { + extensions: Vec, + extension_versions: IndexMap, + extension_info: IndexMap, + /// Keyed as `ltrim($class.'::'.$constant, ':')`; `None` for a reported but undefined constant. + constants: IndexMap>, + classes: IndexMap, + /// `curl_version()`, or null when the curl extension is not loaded. + pub curl_version: PhpMixed, + /// `@inet_pton('::')`. + pub inet_pton_ipv6: PhpMixed, + /// `['Version' => ResourceBundle::create('root', 'ICUDATA', false)->get('Version')]`, or null + /// when the class is absent or the bundle cannot be opened. + pub resource_bundle: PhpMixed, + /// `IntlChar::getUnicodeVersion()`, or null when the class is absent. + pub intl_char_unicode_version: PhpMixed, + /// `(new Imagick())->getVersion()`, or null when the extension is not loaded. + pub imagick: PhpMixed, +} + +impl Default for PlatformInfo { + /// A runtime with no extensions loaded, no classes defined and every reported constant + /// undefined. + fn default() -> Self { + PlatformInfo { + extensions: Vec::new(), + extension_versions: IndexMap::new(), + extension_info: IndexMap::new(), + constants: PLATFORM_CONSTANTS .iter() - .map(|(name, value)| (name.clone(), as_bool(value, name))) + .map(|name| ((*name).to_string(), None)) .collect(), - functions: map_field(payload, "functions") + classes: PLATFORM_CLASSES .iter() - .map(|(name, value)| (name.clone(), as_bool(value, name))) + .map(|name| ((*name).to_string(), false)) .collect(), - ini_settings: map_field(payload, "ini") + curl_version: PhpMixed::Null, + inet_pton_ipv6: PhpMixed::Null, + resource_bundle: PhpMixed::Null, + intl_char_unicode_version: PhpMixed::Null, + imagick: PhpMixed::Null, + } + } +} + +impl PlatformInfo { + /// PHP `get_loaded_extensions()`. + pub fn get_extensions(&self) -> &[String] { + &self.extensions + } + + /// PHP `phpversion($extension)`, with PHP's `false` mapped to `'0'`. + pub fn get_extension_version(&self, extension: &str) -> &str { + self.extension_versions + .get(extension) + .unwrap_or_else(|| { + panic!("PHP RPC: extension `{extension}` is not loaded in the platform payload") + }) + .as_str() + } + + /// PHP `(new \ReflectionExtension($extension))->info()` output. Only the extensions the worker + /// reports can be asked about; any other name is a bug in the caller. + pub fn get_extension_info(&self, extension: &str) -> &str { + self.extension_info + .get(extension) + .unwrap_or_else(|| { + panic!("PHP RPC: extension `{extension}` info is not in the platform payload") + }) + .as_str() + } + + /// PHP `defined(ltrim($class.'::'.$constant, ':'))`. Only the constants the worker reports can + /// be asked about; any other name is a bug in the caller, not an undefined constant. + pub fn has_constant(&self, constant_name: &str, class: Option<&str>) -> bool { + self.constant(constant_name, class).is_some() + } + + /// PHP `constant(ltrim($class.'::'.$constant, ':'))`, answering null for an undefined + /// constant. See [`PlatformInfo::has_constant`] for the fixed set of names. + pub fn get_constant(&self, constant_name: &str, class: Option<&str>) -> PhpMixed { + self.constant(constant_name, class) + .cloned() + .unwrap_or(PhpMixed::Null) + } + + /// PHP `class_exists($class, false)`. See [`PlatformInfo::has_constant`] for the fixed set of + /// names. + pub fn has_class(&self, class: &str) -> bool { + *self.classes.get(class).unwrap_or_else(|| { + panic!("PHP RPC: class `{class}` is not reported by the platform payload") + }) + } + + /// For testing only: reports `extensions` as loaded, each at `version`. + pub fn __set_extensions(&mut self, extensions: Vec, version: &str) { + self.extension_versions = extensions + .iter() + .map(|name| (name.clone(), version.to_string())) + .collect(); + self.extensions = extensions; + } + + /// For testing only: reports `info` as the `ReflectionExtension::info()` output of `extension`. + pub fn __set_extension_info(&mut self, extension: &str, info: &str) { + self.extension_info + .insert(extension.to_string(), info.to_string()); + } + + /// For testing only: reports the constant as defined with `value`. Panics on a constant the + /// worker does not report, so a test cannot describe a runtime the worker cannot report. + pub fn __set_constant(&mut self, constant_name: &str, class: Option<&str>, value: PhpMixed) { + let key = Self::constant_key(constant_name, class); + let entry = self.constants.get_mut(&key).unwrap_or_else(|| { + panic!("PHP RPC: constant `{key}` is not reported by the platform payload") + }); + *entry = Some(value); + } + + /// For testing only: reports the class as defined. See [`PlatformInfo::__set_constant`]. + pub fn __set_class(&mut self, class: &str) { + let entry = self.classes.get_mut(class).unwrap_or_else(|| { + panic!("PHP RPC: class `{class}` is not reported by the platform payload") + }); + *entry = true; + } + + fn constant(&self, constant_name: &str, class: Option<&str>) -> Option<&PhpMixed> { + let key = Self::constant_key(constant_name, class); + self.constants + .get(&key) + .unwrap_or_else(|| { + panic!("PHP RPC: constant `{key}` is not reported by the platform payload") + }) + .as_ref() + } + + fn constant_key(constant_name: &str, class: Option<&str>) -> String { + match class { + Some(class) => format!("{class}::{constant_name}"), + None => constant_name.to_string(), + } + } +} + +static PLATFORM_INFO: OnceLock = OnceLock::new(); + +/// PHP runtime information for `PlatformRepository`. The worker is queried once per process; +/// subsequent calls reuse the cached payload. +pub fn get_platform_info() -> &'static PlatformInfo { + PLATFORM_INFO.get_or_init(|| { + let payload = call("platform", ""); + let payload = payload + .as_array() + .unwrap_or_else(|| panic!("PHP RPC: `platform` did not return an array: {payload:?}")); + let payload = &Payload { + what: "platform", + map: payload, + }; + + let constant_names = payload.string_list("constant_names"); + assert_eq!( + constant_names .iter() - .map(|(name, value)| (name.clone(), as_nullable_string(value, name))) + .map(String::as_str) + .collect::>(), + PLATFORM_CONSTANTS, + "PHP RPC: the worker reports different constants than the platform payload declares" + ); + let constants = payload.map("constants"); + let classes = payload.bool_map("classes"); + assert_eq!( + classes.keys().map(String::as_str).collect::>(), + PLATFORM_CLASSES, + "PHP RPC: the worker reports different classes than the platform payload declares" + ); + + PlatformInfo { + extensions: payload.string_list("extensions"), + extension_versions: payload.string_map("extension_versions"), + extension_info: payload.string_map("extension_info"), + constants: constant_names + .into_iter() + .map(|name| { + let value = constants.and_then(|map| map.get(&name)).cloned(); + (name, value) + }) .collect(), + classes, + curl_version: payload.mixed("curl_version"), + inet_pton_ipv6: payload.mixed("inet_pton_ipv6"), + resource_bundle: payload.mixed("resource_bundle"), + intl_char_unicode_version: payload.mixed("intl_char_unicode_version"), + imagick: payload.mixed("imagick"), } }) } -fn field<'a>(payload: &'a IndexMap, key: &str) -> &'a PhpMixed { - payload - .get(key) - .unwrap_or_else(|| panic!("PHP RPC: `diagnose` payload has no `{key}` entry")) +/// One worker answer, named after the dispatch entry that produced it so a decoding failure says +/// which payload was malformed. +struct Payload<'a> { + what: &'static str, + map: &'a IndexMap, } -fn string_field(payload: &IndexMap, key: &str) -> String { - match field(payload, key) { - PhpMixed::String(s) => s.clone(), - other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not a string: {other:?}"), +impl Payload<'_> { + fn field(&self, key: &str) -> &PhpMixed { + self.map + .get(key) + .unwrap_or_else(|| panic!("PHP RPC: `{}` payload has no `{key}` entry", self.what)) } -} -fn nullable_string_field(payload: &IndexMap, key: &str) -> Option { - as_nullable_string(field(payload, key), key) -} + fn mixed(&self, key: &str) -> PhpMixed { + self.field(key).clone() + } + + fn string(&self, key: &str) -> String { + match self.field(key) { + PhpMixed::String(s) => s.clone(), + other => panic!( + "PHP RPC: `{}` payload entry `{key}` is not a string: {other:?}", + self.what + ), + } + } -fn int_field(payload: &IndexMap, key: &str) -> i64 { - match field(payload, key) { - PhpMixed::Int(n) => *n, - other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not an int: {other:?}"), + fn nullable_string(&self, key: &str) -> Option { + as_nullable_string(self.field(key), self.what, key) } -} -fn bool_field(payload: &IndexMap, key: &str) -> bool { - as_bool(field(payload, key), key) -} + fn int(&self, key: &str) -> i64 { + match self.field(key) { + PhpMixed::Int(n) => *n, + other => panic!( + "PHP RPC: `{}` payload entry `{key}` is not an int: {other:?}", + self.what + ), + } + } -fn map_field<'a>( - payload: &'a IndexMap, - key: &str, -) -> &'a IndexMap { - match field(payload, key) { - PhpMixed::Array(map) => map, - other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not an array: {other:?}"), + fn bool(&self, key: &str) -> bool { + as_bool(self.field(key), self.what, key) + } + + /// `None` for PHP's empty array, which carries no key type and so decodes as an empty list. + fn map(&self, key: &str) -> Option<&IndexMap> { + match self.field(key) { + PhpMixed::Array(map) => Some(map), + PhpMixed::List(items) if items.is_empty() => None, + other => panic!( + "PHP RPC: `{}` payload entry `{key}` is not an array: {other:?}", + self.what + ), + } + } + + fn entries(&self, key: &str) -> impl Iterator { + self.map(key).into_iter().flatten() + } + + fn bool_map(&self, key: &str) -> IndexMap { + self.entries(key) + .map(|(name, value)| (name.clone(), as_bool(value, self.what, name))) + .collect() + } + + fn string_map(&self, key: &str) -> IndexMap { + self.entries(key) + .map(|(name, value)| match value { + PhpMixed::String(s) => (name.clone(), s.clone()), + other => panic!( + "PHP RPC: `{}` payload entry `{key}[{name}]` is not a string: {other:?}", + self.what + ), + }) + .collect() + } + + fn string_list(&self, key: &str) -> Vec { + match self.field(key) { + PhpMixed::List(items) => items + .iter() + .map(|item| match item { + PhpMixed::String(s) => s.clone(), + other => panic!( + "PHP RPC: `{}` payload entry `{key}` has a non-string element: {other:?}", + self.what + ), + }) + .collect(), + other => panic!( + "PHP RPC: `{}` payload entry `{key}` is not a list: {other:?}", + self.what + ), + } } } -fn as_bool(value: &PhpMixed, key: &str) -> bool { +fn as_bool(value: &PhpMixed, what: &str, key: &str) -> bool { match value { PhpMixed::Bool(b) => *b, - other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not a bool: {other:?}"), + other => panic!("PHP RPC: `{what}` payload entry `{key}` is not a bool: {other:?}"), + } +} + +fn as_nullable_string(value: &PhpMixed, what: &str, key: &str) -> Option { + match value { + PhpMixed::String(s) => Some(s.clone()), + PhpMixed::Null => None, + other => { + panic!("PHP RPC: `{what}` payload entry `{key}` is not a string or null: {other:?}") + } } } -fn nullable_int_field(payload: &IndexMap, key: &str) -> Option { - match field(payload, key) { +fn nullable_int_field(payload: &Payload, key: &str) -> Option { + let what = payload.what; + match payload.field(key) { PhpMixed::Int(n) => Some(*n), PhpMixed::Null => None, other => { - panic!("PHP RPC: `diagnose` payload entry `{key}` is not an int or null: {other:?}") + panic!("PHP RPC: `{what}` payload entry `{key}` is not an int or null: {other:?}") } } } -fn curl_field(payload: &IndexMap, key: &str) -> Option { - let curl = match field(payload, key) { +fn curl_field(payload: &Payload, key: &str) -> Option { + let what = payload.what; + let curl = match payload.field(key) { PhpMixed::Null => return None, PhpMixed::Array(map) => map, other => { - panic!("PHP RPC: `diagnose` payload entry `{key}` is not an array or null: {other:?}") + panic!("PHP RPC: `{what}` payload entry `{key}` is not an array or null: {other:?}") } }; + let curl = &Payload { what, map: curl }; Some(Curl { - version: string_field(curl, "version"), - libz_version: nullable_string_field(curl, "libz_version"), - brotli_version: nullable_string_field(curl, "brotli_version"), - ssl_version: nullable_string_field(curl, "ssl_version"), + version: curl.string("version"), + libz_version: curl.nullable_string("libz_version"), + brotli_version: curl.nullable_string("brotli_version"), + ssl_version: curl.nullable_string("ssl_version"), features: nullable_int_field(curl, "features"), version_zstd: nullable_int_field(curl, "version_zstd"), version_http2: nullable_int_field(curl, "version_http2"), - has_http_version_2_0: bool_field(curl, "has_http_version_2_0"), + has_http_version_2_0: curl.bool("has_http_version_2_0"), version_http3: nullable_int_field(curl, "version_http3"), }) } -fn as_nullable_string(value: &PhpMixed, key: &str) -> Option { - match value { - PhpMixed::String(s) => Some(s.clone()), - PhpMixed::Null => None, - other => { - panic!("PHP RPC: `diagnose` payload entry `{key}` is not a string or null: {other:?}") - } - } -} - /// PHP `phpversion($extension)`. pub fn phpversion(extension: &str) -> Option { match call("phpversion", extension) { @@ -281,11 +536,6 @@ pub fn phpversion(extension: &str) -> Option { } } -/// PHP `get_loaded_extensions()`. -pub fn get_loaded_extensions() -> Vec { - string_list(call("get_loaded_extensions", ""), "get_loaded_extensions") -} - /// `Composer\XdebugHandler\XdebugHandler::getAllIniFiles()` (minus the `self::$name` branch, /// which is unreachable since this port never constructs an XdebugHandler): `[(string) /// php_ini_loaded_file()]` merged with the trimmed, comma-split `php_ini_scanned_files()` list @@ -851,12 +1101,6 @@ mod tests { return; } - let extensions = get_loaded_extensions(); - assert!( - extensions.iter().any(|extension| extension == "Core"), - "expected the Core extension among {extensions:?}", - ); - // XdebugHandler::getAllIniFiles() always yields at least one entry, which is the empty // string when no php.ini is loaded. assert!(!get_all_ini_files().is_empty()); @@ -910,20 +1154,38 @@ mod tests { } #[test] - fn queries_constants_when_php_available() { + fn queries_platform_info_when_php_available() { if PhpExecutableFinder::new().find(false).is_none() { // No PHP in this environment; the worker cannot start. return; } - assert!(has_constant("PHP_VERSION")); - assert!(!has_constant("SHIRABE_DOES_NOT_EXIST_XYZ")); + let platform_info = get_platform_info(); - assert_eq!(get_constant("PHP_INT_SIZE"), PhpMixed::Int(8)); - assert_eq!(get_constant("SHIRABE_DOES_NOT_EXIST_XYZ"), PhpMixed::Null); - match get_constant("PHP_VERSION") { + let extensions = platform_info.get_extensions(); + assert!( + extensions.iter().any(|extension| extension == "Core"), + "expected the Core extension among {extensions:?}", + ); + assert_eq!( + platform_info.get_extension_version("Core"), + get_php_version() + ); + + assert!(platform_info.has_constant("PHP_VERSION", None)); + assert_eq!( + platform_info.get_constant("PHP_INT_SIZE", None), + PhpMixed::Int(8) + ); + match platform_info.get_constant("PHP_VERSION", None) { PhpMixed::String(s) => assert!(!s.is_empty(), "expected a non-empty PHP_VERSION"), other => panic!("expected a string, got {other:?}"), } } + + #[test] + #[should_panic(expected = "is not reported by the platform payload")] + fn platform_info_rejects_an_unreported_constant() { + PlatformInfo::default().has_constant("SHIRABE_DOES_NOT_EXIST_XYZ", None); + } } diff --git a/crates/shirabe/src/command/base_dependency_command.rs b/crates/shirabe/src/command/base_dependency_command.rs index c4399f34..46cc654f 100644 --- a/crates/shirabe/src/command/base_dependency_command.rs +++ b/crates/shirabe/src/command/base_dependency_command.rs @@ -78,7 +78,7 @@ pub trait BaseDependencyCommand: BaseCommand { .map(|(k, v)| (k, PhpMixed::String(v))) .collect(); repos.push(crate::repository::RepositoryInterfaceHandle::new( - PlatformRepository::new(vec![], platform_overrides)?, + PlatformRepository::new(vec![], platform_overrides, None, None)?, )); } else { let repository_manager = composer.get_repository_manager().clone(); @@ -109,7 +109,7 @@ pub trait BaseDependencyCommand: BaseCommand { .into_iter() .collect(); repos.push(crate::repository::RepositoryInterfaceHandle::new( - PlatformRepository::new(vec![], platform_overrides)?, + PlatformRepository::new(vec![], platform_overrides, None, None)?, )); } diff --git a/crates/shirabe/src/command/check_platform_reqs_command.rs b/crates/shirabe/src/command/check_platform_reqs_command.rs index 903e0c4c..4e105e69 100644 --- a/crates/shirabe/src/command/check_platform_reqs_command.rs +++ b/crates/shirabe/src/command/check_platform_reqs_command.rs @@ -290,7 +290,7 @@ impl Command for CheckPlatformReqsCommand { requires_sorted.sort_by(|a, b| a.0.cmp(&b.0)); installed_repo.add_repository(crate::repository::RepositoryInterfaceHandle::new( - PlatformRepository::new(vec![], indexmap::IndexMap::new())?, + PlatformRepository::new(vec![], indexmap::IndexMap::new(), None, None)?, )); let installed_repo_with_platform = installed_repo; diff --git a/crates/shirabe/src/command/completion_trait.rs b/crates/shirabe/src/command/completion_trait.rs index bdd42dff..67ab23be 100644 --- a/crates/shirabe/src/command/completion_trait.rs +++ b/crates/shirabe/src/command/completion_trait.rs @@ -80,14 +80,14 @@ pub trait CompletionTrait: BaseCommand { .into_iter() .map(|(k, v)| (k, PhpMixed::String(v))) .collect(); - PlatformRepository::new(vec![], overrides)? + PlatformRepository::new(vec![], overrides, None, None)? } else { let platform_cfg = composer.get_config().borrow().get("platform"); let overrides: IndexMap = platform_cfg .as_array() .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); - PlatformRepository::new(vec![], overrides)? + PlatformRepository::new(vec![], overrides, None, None)? }; if input.get_completion_value().is_empty() { // to reduce noise, when no text is yet entered we list only two entries for ext- and lib- prefixes @@ -286,7 +286,7 @@ pub trait CompletionTrait: BaseCommand { .as_array() .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); - let mut repos = PlatformRepository::new(vec![], overrides)?; + let mut repos = PlatformRepository::new(vec![], overrides, None, None)?; let pattern = base_package::package_name_to_regexp(&format!("{}*", input.get_completion_value())); diff --git a/crates/shirabe/src/command/create_project_command.rs b/crates/shirabe/src/command/create_project_command.rs index dcf0e0f8..aa1b79a8 100644 --- a/crates/shirabe/src/command/create_project_command.rs +++ b/crates/shirabe/src/command/create_project_command.rs @@ -684,6 +684,8 @@ impl CreateProjectCommand { .collect(), _ => indexmap::IndexMap::new(), }, + None, + None, )?; // find the latest version if there are multiple diff --git a/crates/shirabe/src/command/diagnose_command.rs b/crates/shirabe/src/command/diagnose_command.rs index 8483eb56..65240e51 100644 --- a/crates/shirabe/src/command/diagnose_command.rs +++ b/crates/shirabe/src/command/diagnose_command.rs @@ -1223,7 +1223,7 @@ impl Command for DiagnoseCommand { let platform_overrides_unboxed: indexmap::IndexMap = platform_overrides.into_iter().collect(); let mut platform_repo = - PlatformRepository::new(vec![], platform_overrides_unboxed).unwrap(); + PlatformRepository::new(vec![], platform_overrides_unboxed, None, None).unwrap(); let php_pkg = ::find_package( &mut platform_repo, "php", diff --git a/crates/shirabe/src/command/init_command.rs b/crates/shirabe/src/command/init_command.rs index f2bf19d7..5168b2ba 100644 --- a/crates/shirabe/src/command/init_command.rs +++ b/crates/shirabe/src/command/init_command.rs @@ -830,7 +830,7 @@ impl Command for InitCommand { let mut repos: Vec = vec![crate::repository::RepositoryInterfaceHandle::new( - PlatformRepository::new(vec![], IndexMap::new())?, + PlatformRepository::new(vec![], IndexMap::new(), None, None)?, )]; let mut create_default_packagist_repo = true; for repo in &repositories { diff --git a/crates/shirabe/src/command/package_discovery_trait.rs b/crates/shirabe/src/command/package_discovery_trait.rs index 5c921e95..db7db488 100644 --- a/crates/shirabe/src/command/package_discovery_trait.rs +++ b/crates/shirabe/src/command/package_discovery_trait.rs @@ -42,7 +42,7 @@ pub trait PackageDiscoveryTrait: BaseCommand { // PHP: array_merge([new PlatformRepository], RepositoryFactory::defaultReposWithDefaultManager($this->getIO())) let mut repos: Vec = vec![crate::repository::RepositoryInterfaceHandle::new( - PlatformRepository::new(vec![], IndexMap::new()) + PlatformRepository::new(vec![], IndexMap::new(), None, None) .expect("PlatformRepository::new should not fail"), )]; let io_owned: std::rc::Rc> = self.get_io(); diff --git a/crates/shirabe/src/command/require_command.rs b/crates/shirabe/src/command/require_command.rs index f4410c19..85e53a37 100644 --- a/crates/shirabe/src/command/require_command.rs +++ b/crates/shirabe/src/command/require_command.rs @@ -916,8 +916,12 @@ impl Command for RequireCommand { .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); // initialize self.repos as it is used by the PackageDiscoveryTrait - let platform_repo = - PlatformRepositoryHandle::new(PlatformRepository::new(vec![], platform_overrides_map)?); + let platform_repo = PlatformRepositoryHandle::new(PlatformRepository::new( + vec![], + platform_overrides_map, + None, + None, + )?); let mut combined: Vec = vec![platform_repo.clone().into()]; for repo in repos { diff --git a/crates/shirabe/src/command/search_command.rs b/crates/shirabe/src/command/search_command.rs index 0b675109..4bf75c8b 100644 --- a/crates/shirabe/src/command/search_command.rs +++ b/crates/shirabe/src/command/search_command.rs @@ -111,7 +111,7 @@ impl Command for SearchCommand { input: std::rc::Rc>, output: std::rc::Rc>, ) -> anyhow::Result { - let platform_repo = PlatformRepository::new4(vec![], IndexMap::new(), None, None)?; + let platform_repo = PlatformRepository::new(vec![], IndexMap::new(), None, None)?; let io = self.get_io(); let format = input diff --git a/crates/shirabe/src/command/show_command.rs b/crates/shirabe/src/command/show_command.rs index 44733531..ff20f325 100644 --- a/crates/shirabe/src/command/show_command.rs +++ b/crates/shirabe/src/command/show_command.rs @@ -1784,8 +1784,12 @@ impl Command for ShowCommand { platform_overrides = p.into_iter().collect(); } } - let platform_repo = - PlatformRepositoryHandle::new(PlatformRepository::new(vec![], platform_overrides)?); + let platform_repo = PlatformRepositoryHandle::new(PlatformRepository::new( + vec![], + platform_overrides, + None, + None, + )?); let mut locked_repo: Option = None; // The single-package $package binding from PHP gets surfaced here. diff --git a/crates/shirabe/src/command/suggests_command.rs b/crates/shirabe/src/command/suggests_command.rs index 5c73da9b..96989dc0 100644 --- a/crates/shirabe/src/command/suggests_command.rs +++ b/crates/shirabe/src/command/suggests_command.rs @@ -133,6 +133,8 @@ impl Command for SuggestsCommand { installed_repos.push(RepositoryInterfaceHandle::new(PlatformRepository::new( vec![], platform_overrides, + None, + None, )?)); let locked_repo = composer.get_locker().borrow_mut().get_locked_repository( !input @@ -151,6 +153,8 @@ impl Command for SuggestsCommand { installed_repos.push(RepositoryInterfaceHandle::new(PlatformRepository::new( vec![], platform_overrides, + None, + None, )?)); installed_repos.push( composer diff --git a/crates/shirabe/src/dependency_resolver/problem.rs b/crates/shirabe/src/dependency_resolver/problem.rs index 09adafea..8ee4b816 100644 --- a/crates/shirabe/src/dependency_resolver/problem.rs +++ b/crates/shirabe/src/dependency_resolver/problem.rs @@ -458,7 +458,7 @@ impl Problem { ); // Per-extension version info can't be known statically; query the real PHP - // runtime via the RPC bridge, same as platform::runtime::Runtime::get_extension_version. + // runtime via the RPC bridge, as PHP's Composer\Platform\Runtime does. let runtime_version = shirabe_php_rpc::phpversion(&ext); let effective_version = match runtime_version { None => "0".to_string(), diff --git a/crates/shirabe/src/installer.rs b/crates/shirabe/src/installer.rs index bda4e2f6..091fb35f 100644 --- a/crates/shirabe/src/installer.rs +++ b/crates/shirabe/src/installer.rs @@ -1323,6 +1323,8 @@ impl Installer { Ok(PlatformRepositoryHandle::new(PlatformRepository::new( vec![], platform_overrides, + None, + None, )?)) } diff --git a/crates/shirabe/src/platform.rs b/crates/shirabe/src/platform.rs index 6abbb769..11b00e3f 100644 --- a/crates/shirabe/src/platform.rs +++ b/crates/shirabe/src/platform.rs @@ -1,7 +1,9 @@ +//! `Composer\Platform\Runtime` has no Rust counterpart. Its work belongs to the running PHP +//! interpreter, so it is ported as PHP into the RPC worker (`ShirabePlatformRuntime` in +//! `shirabe-php-rpc`), and its callers read the answers off `shirabe_php_rpc::PlatformInfo`. + pub mod hhvm_detector; -pub mod runtime; pub mod version; pub use hhvm_detector::*; -pub use runtime::*; pub use version::*; diff --git a/crates/shirabe/src/platform/runtime.rs b/crates/shirabe/src/platform/runtime.rs deleted file mode 100644 index 265f007c..00000000 --- a/crates/shirabe/src/platform/runtime.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! ref: composer/src/Composer/Platform/Runtime.php - -use indexmap::IndexMap; -use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; -use shirabe_php_rpc::{PhpThrow, PluginValue}; -use shirabe_php_shim::{ - PhpMixed, RuntimeException, function_exists, html_entity_decode, implode, ltrim, php_regex, - strip_tags, trim, -}; - -/// Seam over the PHP runtime so PlatformRepository can be tested against mocked -/// extension/constant/function probes. PHP has no such interface (the test mocks the -/// concrete `Composer\Platform\Runtime` directly); it is introduced here to keep the -/// consumer dependent only on trait methods. -pub trait RuntimeInterface: std::fmt::Debug { - fn has_constant(&self, constant_name: &str, class: Option) -> bool; - fn get_constant(&self, constant_name: &str, class: Option) -> PhpMixed; - /// `callable` carries the PHP callable spec (a function name string or a - /// `[class, method]` list), matching PHP `invoke($callable, $arguments)`. - fn invoke(&self, callable: PhpMixed, arguments: Vec) -> PhpMixed; - fn has_class(&self, class: &str) -> bool; - fn construct(&self, class: &str, arguments: Vec) -> anyhow::Result; - fn get_extensions(&self) -> Vec; - fn get_extension_version(&self, extension: &str) -> String; - fn get_extension_info(&self, extension: &str) -> anyhow::Result; -} - -#[derive(Debug)] -pub struct Runtime; - -impl RuntimeInterface for Runtime { - fn has_constant(&self, constant_name: &str, class: Option) -> bool { - shirabe_php_rpc::has_constant(<rim( - &format!("{}::{}", class.as_deref().unwrap_or(""), constant_name), - Some(":"), - )) - } - - fn get_constant(&self, constant_name: &str, class: Option) -> PhpMixed { - shirabe_php_rpc::get_constant(<rim( - &format!("{}::{}", class.as_deref().unwrap_or(""), constant_name), - Some(":"), - )) - } - - fn invoke(&self, callable: PhpMixed, arguments: Vec) -> PhpMixed { - // PHP: return $callable(...$arguments); - // Only the specific dynamic callables PlatformRepository actually reaches are - // wired through php-rpc; arbitrary PHP callables are still unsupported. - match (&callable, arguments.as_slice()) { - (PhpMixed::String(name), [PhpMixed::String(arg)]) if name == "inet_pton" => { - shirabe_php_rpc::inet_pton(arg) - } - (PhpMixed::String(name), []) if name == "curl_version" => { - let mut version = IndexMap::new(); - if let Some(v) = shirabe_php_rpc::curl_version() { - version.insert("version".to_string(), PhpMixed::String(v)); - } - PhpMixed::Array(version) - } - (PhpMixed::List(spec), _) => match class_callable(spec) { - ("ResourceBundle", "create") => resource_bundle_create(arguments), - ("IntlChar", "getUnicodeVersion") => { - php_value(shirabe_php_rpc::call_static_method( - "IntlChar", - "getUnicodeVersion", - Vec::new(), - None, - )) - } - (class, method) => panic!( - "the PHP callable `{class}::{method}` is not wired through the runtime seam" - ), - }, - _ => panic!("the PHP callable {callable:?} is not wired through the runtime seam"), - } - } - - fn has_class(&self, class: &str) -> bool { - shirabe_php_rpc::class_exists(class) - } - - fn construct(&self, class: &str, arguments: Vec) -> anyhow::Result { - match class { - "Imagick" => imagick_version(arguments), - other => Err(anyhow::anyhow!(RuntimeException { - message: format!("the PHP class `{other}` is not wired through the runtime seam"), - code: 0, - })), - } - } - - fn get_extensions(&self) -> Vec { - shirabe_php_rpc::get_loaded_extensions() - } - - fn get_extension_version(&self, extension: &str) -> String { - shirabe_php_rpc::phpversion(extension).unwrap_or_else(|| "0".to_string()) - } - - fn get_extension_info(&self, extension: &str) -> anyhow::Result { - Ok(shirabe_php_rpc::get_extension_info(extension)) - } -} - -/// The `[class, method]` pair of a PHP callable given in array form. -fn class_callable(spec: &[PhpMixed]) -> (&str, &str) { - match spec { - [PhpMixed::String(class), PhpMixed::String(method)] => (class, method), - other => panic!("a PHP callable given as an array must be [class, method], got {other:?}"), - } -} - -/// Unwraps an RPC outcome whose failure means the runtime probe itself is broken, not that the -/// probed extension is absent. -fn php_value(outcome: anyhow::Result>) -> PhpMixed { - match outcome { - Ok(Ok(value)) => value - .to_php_mixed() - .expect("a runtime probe answers with plain values"), - Ok(Err(throw)) => panic!("the PHP runtime probe failed: {}", throw.message), - Err(e) => panic!("the PHP runtime probe could not be sent: {e:#}"), - } -} - -/// PHP `ResourceBundle::create(...)`, whose result the caller reads `->get('Version')` off. -/// A live PHP object has no `PhpMixed` counterpart, so that entry crosses in its place. -fn resource_bundle_create(arguments: Vec) -> PhpMixed { - let bundle = match php_handle(shirabe_php_rpc::call_static_method( - "ResourceBundle", - "create", - arguments.iter().map(PluginValue::from_php_mixed).collect(), - None, - )) { - Some(phandle) => phandle, - // PHP returns null when the bundle cannot be opened. - None => return PhpMixed::Null, - }; - let version = php_value(shirabe_php_rpc::call_php_method( - bundle, - "get", - vec![PluginValue::string("Version")], - None, - )); - let _ = shirabe_php_rpc::release_php_handle(bundle); - PhpMixed::Object(IndexMap::from([("Version".to_string(), version)])) -} - -/// PHP `(new Imagick())->getVersion()`, reported as the entries the caller reads. -fn imagick_version(arguments: Vec) -> anyhow::Result { - let imagick = php_handle(shirabe_php_rpc::new_object( - "Imagick", - arguments.iter().map(PluginValue::from_php_mixed).collect(), - None, - )) - .ok_or_else(|| { - anyhow::anyhow!(RuntimeException { - message: "`new Imagick` did not answer with an object".to_string(), - code: 0, - }) - })?; - let version = php_value(shirabe_php_rpc::call_php_method( - imagick, - "getVersion", - Vec::new(), - None, - )); - let _ = shirabe_php_rpc::release_php_handle(imagick); - Ok(version) -} - -/// The handle of a PHP-side object an RPC answered with, or `None` when it answered with null. -fn php_handle(outcome: anyhow::Result>) -> Option { - match outcome { - Ok(Ok(PluginValue::PhpHandle(handle))) => Some(handle.phandle), - Ok(Ok(PluginValue::Null)) => None, - Ok(Ok(other)) => panic!("the PHP runtime probe answered with {other:?}, not an object"), - Ok(Err(throw)) => panic!("the PHP runtime probe failed: {}", throw.message), - Err(e) => panic!("the PHP runtime probe could not be sent: {e:#}"), - } -} - -impl Runtime { - pub fn has_function(&self, f: &str) -> bool { - function_exists(f) - } - - pub fn parse_html_extension_info(html: &str) -> String { - let mut result: Vec = vec![]; - - let mut matches: IndexMap = IndexMap::new(); - if Preg::match3( - php_regex!(r"~

\s*]*>([^<]+)\s*

~i"), - html, - Some(&mut matches), - ) { - result.push(trim( - &html_entity_decode( - matches - .get(&CaptureKey::ByIndex(1)) - .map(|s| s.as_str()) - .unwrap_or(""), - ), - None, - )); - result.push(String::new()); - } - - let mut matches: IndexMap> = IndexMap::new(); - if Preg::match_all3( - php_regex!( - r#"~\s*\s*(.*?)\s*\s*\s*(.*?)\s*\s*~is"# - ), - html, - Some(&mut matches), - ) > 0 - { - let group1 = matches - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_default(); - let group2 = matches - .get(&CaptureKey::ByIndex(2)) - .cloned() - .unwrap_or_default(); - let count = std::cmp::min(group1.len(), group2.len()); - - for i in 0..count { - let key = trim(&html_entity_decode(&strip_tags(&group1[i])), None); - let value = trim(&html_entity_decode(&strip_tags(&group2[i])), None); - result.push(format!("{} => {}", key, value)); - } - } - - implode("\n", &result) - } -} diff --git a/crates/shirabe/src/repository/platform_repository.rs b/crates/shirabe/src/repository/platform_repository.rs index 5b205ba0..944bb4db 100644 --- a/crates/shirabe/src/repository/platform_repository.rs +++ b/crates/shirabe/src/repository/platform_repository.rs @@ -11,16 +11,14 @@ use crate::package::PackageInterfaceHandle; use crate::package::version::VersionParser; use crate::platform::HhvmDetector; use crate::platform::HhvmDetectorInterface; -use crate::platform::Runtime; -use crate::platform::RuntimeInterface; use crate::platform::Version; use crate::plugin::plugin_interface::{self}; use crate::repository::ArrayRepository; use crate::repository::RepositoryInterface; -use crate::util::Silencer; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; use shirabe_external_packages::composer::xdebug_handler::XdebugHandler; +use shirabe_php_rpc::PlatformInfo; use shirabe_php_shim::{ InvalidArgumentException, PhpMixed, UnexpectedValueException, array_map_str_fn, array_slice_strs, explode, get_class, implode, in_array_strict, is_string, php_regex, @@ -47,27 +45,21 @@ pub struct PlatformRepository { pub(crate) version_parser: Option, pub(crate) overrides: IndexMap, pub(crate) disabled_packages: IndexMap, - pub(crate) runtime: Box, + pub(crate) platform_info: Option, pub(crate) hhvm_detector: Box, } impl PlatformRepository { const PLATFORM_PACKAGE_REGEX: &'static str = "{^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$}iD"; + /// A `None` `platform_info` is resolved from the PHP worker on the first `initialize()`, so + /// constructing the repository on its own never starts the worker. pub fn new( packages: Vec, overrides: IndexMap, - ) -> anyhow::Result { - Self::new4(packages, overrides, None, None) - } - - pub fn new4( - packages: Vec, - overrides: IndexMap, - runtime: Option>, + platform_info: Option, hhvm_detector: Option>, ) -> anyhow::Result { - let runtime: Box = runtime.unwrap_or_else(|| Box::new(Runtime)); let hhvm_detector: Box = hhvm_detector.unwrap_or_else(|| Box::new(HhvmDetector::new(None, None))); let mut overrides_map: IndexMap = IndexMap::new(); @@ -107,7 +99,7 @@ impl PlatformRepository { version_parser: None, overrides: overrides_map, disabled_packages: IndexMap::new(), - runtime, + platform_info, hhvm_detector, }; for package in packages { @@ -139,6 +131,11 @@ impl PlatformRepository { pub(crate) fn initialize(&mut self) -> anyhow::Result<()> { self.inner.initialize(); + let platform_info = self + .platform_info + .get_or_insert_with(|| shirabe_php_rpc::get_platform_info().clone()) + .clone(); + let mut libraries: IndexMap = IndexMap::new(); self.version_parser = Some(VersionParser::new()); @@ -214,7 +211,7 @@ impl PlatformRepository { CompletePackageHandle::from_complete_package(composer_runtime_api).into(), )?; - let php_version_const = self.runtime.get_constant("PHP_VERSION", None); + let php_version_const = platform_info.get_constant("PHP_VERSION", None); let php_version_str = match &php_version_const { PhpMixed::String(s) => s.clone(), _ => "".to_string(), @@ -245,8 +242,7 @@ impl PlatformRepository { php.set_description("The PHP interpreter".to_string()); self.add_package(CompletePackageHandle::from_complete_package(php).into())?; - if self - .runtime + if platform_info .get_constant("PHP_DEBUG", None) .as_bool() .unwrap_or(false) @@ -260,9 +256,8 @@ impl PlatformRepository { self.add_package(CompletePackageHandle::from_complete_package(phpdebug).into())?; } - if self.runtime.has_constant("PHP_ZTS", None) - && self - .runtime + if platform_info.has_constant("PHP_ZTS", None) + && platform_info .get_constant("PHP_ZTS", None) .as_bool() .unwrap_or(false) @@ -276,8 +271,7 @@ impl PlatformRepository { self.add_package(CompletePackageHandle::from_complete_package(phpzts).into())?; } - if self - .runtime + if platform_info .get_constant("PHP_INT_SIZE", None) .as_int() .map(|v| v == 8) @@ -294,15 +288,8 @@ impl PlatformRepository { // The AF_INET6 constant is only defined if ext-sockets is available but // IPv6 support might still be available. - let has_inet6 = self.runtime.has_constant("AF_INET6", None); - // PHP: Silencer::call([$this->runtime, 'invoke'], 'inet_pton', ['::']) - let inet_pton_check = Silencer::call(|| { - Ok::(self.runtime.invoke( - PhpMixed::String("inet_pton".to_string()), - vec![PhpMixed::String("::".to_string())], - )) - }) - .unwrap_or(PhpMixed::Bool(false)); + let has_inet6 = platform_info.has_constant("AF_INET6", None); + let inet_pton_check = &platform_info.inet_pton_ipv6; if has_inet6 || !matches!(inet_pton_check, PhpMixed::Bool(false)) { let mut php_ipv6 = CompletePackage::new("php-ipv6".to_string(), version, pretty_version); @@ -310,7 +297,7 @@ impl PlatformRepository { self.add_package(CompletePackageHandle::from_complete_package(php_ipv6).into())?; } - let loaded_extensions = self.runtime.get_extensions(); + let loaded_extensions = platform_info.get_extensions().to_vec(); // Extensions scanning for name in &loaded_extensions { @@ -318,7 +305,7 @@ impl PlatformRepository { continue; } - self.add_extension(name, &self.runtime.get_extension_version(name))?; + self.add_extension(name, platform_info.get_extension_version(name))?; } // Check for Xdebug in a restarted process @@ -340,13 +327,13 @@ impl PlatformRepository { for name in &loaded_extensions { match name.as_str() { "amqp" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // librabbitmq version => 0.9.0 let mut librabbitmq_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^librabbitmq version => (?.+)$/im"), - &info, + info, Some(&mut librabbitmq_matches), ) { self.add_library( @@ -365,7 +352,7 @@ impl PlatformRepository { let mut protocol_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^AMQP protocol version => (?.+)$/im"), - &info, + info, Some(&mut protocol_matches), ) { let version_str = protocol_matches @@ -384,13 +371,13 @@ impl PlatformRepository { } "bz2" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // BZip2 Version => 1.0.6, 6-Sept-2010 let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^BZip2 Version => (?.*),/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -407,9 +394,7 @@ impl PlatformRepository { } "curl" => { - let curl_version = self - .runtime - .invoke(PhpMixed::String("curl_version".to_string()), vec![]); + let curl_version = &platform_info.curl_version; let curl_version_str = curl_version .as_array() .and_then(|m| m.get("version")) @@ -419,13 +404,13 @@ impl PlatformRepository { self.add_library(&mut libraries, name, Some(cv), None, &[], &[])?; } - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // SSL Version => OpenSSL/1.0.1t let mut ssl_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("{^SSL Version => (?[^/]+)/(?.+)$}im"), - &info, + info, Some(&mut ssl_matches), ) { let ssl_library_raw = ssl_matches @@ -495,7 +480,7 @@ impl PlatformRepository { php_regex!( "{^libSSH Version => (?[^/]+)/(?.+?)(?:/.*)?$}im" ), - &info, + info, Some(&mut ssh_matches), ) { let ssh_library = ssh_matches @@ -520,7 +505,7 @@ impl PlatformRepository { let mut zlib_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("{^ZLib Version => (?.+)$}im"), - &info, + info, Some(&mut zlib_matches), ) { self.add_library( @@ -537,13 +522,13 @@ impl PlatformRepository { } "date" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // timelib version => 2018.03 let mut timelib_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^timelib version => (?.+)$/im"), - &info, + info, Some(&mut timelib_matches), ) { self.add_library( @@ -562,7 +547,7 @@ impl PlatformRepository { let mut zoneinfo_source_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^Timezone Database => (?internal|external)$/im"), - &info, + info, Some(&mut zoneinfo_source_matches), ) { let external = zoneinfo_source_matches @@ -574,7 +559,7 @@ impl PlatformRepository { php_regex!( "/^\"Olson\" Timezone Database Version => (?.+?)(?:\\.system)?$/im" ), - &info, + info, Some(&mut zoneinfo_matches), ) { let zoneinfo_version = zoneinfo_matches @@ -608,13 +593,13 @@ impl PlatformRepository { } "fileinfo" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // libmagic => 537 let mut magic_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libmagic => (?.+)$/im"), - &info, + info, Some(&mut magic_matches), ) { self.add_library( @@ -631,7 +616,7 @@ impl PlatformRepository { } "gd" => { - let gd_version = self.runtime.get_constant("GD_VERSION", None); + let gd_version = platform_info.get_constant("GD_VERSION", None); let gd_version_str = match &gd_version { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -645,12 +630,12 @@ impl PlatformRepository { &[], )?; - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut libjpeg_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libJPEG Version => (?.+?)(?: compatible)?$/im"), - &info, + info, Some(&mut libjpeg_matches), ) { let libjpeg_version = libjpeg_matches @@ -671,7 +656,7 @@ impl PlatformRepository { let mut libpng_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libPNG Version => (?.+)$/im"), - &info, + info, Some(&mut libpng_matches), ) { self.add_library( @@ -689,7 +674,7 @@ impl PlatformRepository { let mut freetype_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^FreeType Version => (?.+)$/im"), - &info, + info, Some(&mut freetype_matches), ) { self.add_library( @@ -707,7 +692,7 @@ impl PlatformRepository { let mut libxpm_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libXpm Version => (?\\d+)$/im"), - &info, + info, Some(&mut libxpm_matches), ) { let version_id: i64 = libxpm_matches @@ -727,7 +712,7 @@ impl PlatformRepository { } "gmp" => { - let gmp_version = self.runtime.get_constant("GMP_VERSION", None); + let gmp_version = platform_info.get_constant("GMP_VERSION", None); let gmp_version_str = match &gmp_version { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -743,7 +728,7 @@ impl PlatformRepository { } "iconv" => { - let iconv_version = self.runtime.get_constant("ICONV_VERSION", None); + let iconv_version = platform_info.get_constant("ICONV_VERSION", None); let iconv_version_str = match &iconv_version { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -759,12 +744,12 @@ impl PlatformRepository { } "intl" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let description = "The ICU unicode and globalization support library"; // Truthy check is for testing only so we can make the condition fail - if self.runtime.has_constant("INTL_ICU_VERSION", None) { - let intl_icu_version = self.runtime.get_constant("INTL_ICU_VERSION", None); + if platform_info.has_constant("INTL_ICU_VERSION", None) { + let intl_icu_version = platform_info.get_constant("INTL_ICU_VERSION", None); let intl_icu_str = match &intl_icu_version { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -781,7 +766,7 @@ impl PlatformRepository { let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^ICU version => (?.+)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -801,7 +786,7 @@ impl PlatformRepository { let mut zoneinfo_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^ICU TZData version => (?.*)$/im"), - &info, + info, Some(&mut zoneinfo_matches), ) { let zi_version = zoneinfo_matches @@ -821,22 +806,11 @@ impl PlatformRepository { } // Add a separate version for the CLDR library version - if self.runtime.has_class("ResourceBundle") { - let resource_bundle = self.runtime.invoke( - PhpMixed::List(vec![ - PhpMixed::String("ResourceBundle".to_string()), - PhpMixed::String("create".to_string()), - ]), - vec![ - PhpMixed::String("root".to_string()), - PhpMixed::String("ICUDATA".to_string()), - PhpMixed::Bool(false), - ], - ); + if platform_info.has_class("ResourceBundle") { + let resource_bundle = &platform_info.resource_bundle; if !matches!(resource_bundle, PhpMixed::Null) { - // TODO(plugin): `$resourceBundle->get('Version')` dynamic method call let version_value = - Self::resource_bundle_get(&resource_bundle, "Version"); + Self::resource_bundle_get(resource_bundle, "Version"); let version_str = match version_value { PhpMixed::String(s) => Some(s), _ => None, @@ -852,16 +826,10 @@ impl PlatformRepository { } } - if self.runtime.has_class("IntlChar") { - let intl_char_versions = self.runtime.invoke( - PhpMixed::List(vec![ - PhpMixed::String("IntlChar".to_string()), - PhpMixed::String("getUnicodeVersion".to_string()), - ]), - vec![], - ); + if platform_info.has_class("IntlChar") { + let intl_char_versions = &platform_info.intl_char_unicode_version; let sliced = - shirabe_php_shim::array_slice_mixed(&intl_char_versions, 0, Some(3)); + shirabe_php_shim::array_slice_mixed(intl_char_versions, 0, Some(3)); let joined = implode(".", &Self::php_array_to_string_vec(&sliced)); self.add_library( &mut libraries, @@ -875,10 +843,9 @@ impl PlatformRepository { } "imagick" => { - let image_magick_version = self.runtime.construct("Imagick", Vec::new())?; - // TODO(plugin): `->getVersion()` is a dynamic method call on Imagick + let image_magick_version = &platform_info.imagick; let image_magick_version_str = - Self::imagick_get_version_string(&image_magick_version); + Self::imagick_get_version_string(image_magick_version); // 6.x: ImageMagick 6.2.9 08/24/06 Q16 http://www.imagemagick.org // 7.x: ImageMagick 7.0.8-34 Q16 x86_64 2019-03-23 https://imagemagick.org let mut matches: IndexMap = IndexMap::new(); @@ -907,17 +874,17 @@ impl PlatformRepository { } "ldap" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut matches: IndexMap = IndexMap::new(); let mut vendor_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^Vendor Version => (?\\d+)$/im"), - &info, + info, Some(&mut matches), ) && Preg::is_match3( php_regex!("/^Vendor Name => (?.+)$/im"), - &info, + info, Some(&mut vendor_matches), ) { let version_id: i64 = matches @@ -950,7 +917,7 @@ impl PlatformRepository { .collect(); let libxml_provides: Vec = array_map_str_fn(|extension| format!("{}-libxml", extension), &intersected); - let libxml_dotted = self.runtime.get_constant("LIBXML_DOTTED_VERSION", None); + let libxml_dotted = platform_info.get_constant("LIBXML_DOTTED_VERSION", None); let libxml_dotted_str = match &libxml_dotted { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -966,13 +933,13 @@ impl PlatformRepository { } "mbstring" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // libmbfl version => 1.3.2 let mut libmbfl_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libmbfl version => (?.+)$/im"), - &info, + info, Some(&mut libmbfl_matches), ) { self.add_library( @@ -987,8 +954,8 @@ impl PlatformRepository { )?; } - if self.runtime.has_constant("MB_ONIGURUMA_VERSION", None) { - let oniguruma = self.runtime.get_constant("MB_ONIGURUMA_VERSION", None); + if platform_info.has_constant("MB_ONIGURUMA_VERSION", None) { + let oniguruma = platform_info.get_constant("MB_ONIGURUMA_VERSION", None); let oniguruma_str = match &oniguruma { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -1010,7 +977,7 @@ impl PlatformRepository { php_regex!( "/^(?:oniguruma|Multibyte regex \\(oniguruma\\)) version => (?.+)$/im" ), - &info, + info, Some(&mut oniguruma_matches), ) { self.add_library( @@ -1028,13 +995,13 @@ impl PlatformRepository { } "memcached" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // libmemcached version => 1.0.18 let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libmemcached version => (?.+)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1051,7 +1018,7 @@ impl PlatformRepository { } "openssl" => { - let openssl_text = self.runtime.get_constant("OPENSSL_VERSION_TEXT", None); + let openssl_text = platform_info.get_constant("OPENSSL_VERSION_TEXT", None); let openssl_text_str = match &openssl_text { PhpMixed::String(s) => s.clone(), _ => "".to_string(), @@ -1086,7 +1053,7 @@ impl PlatformRepository { } "pcre" => { - let pcre_version = self.runtime.get_constant("PCRE_VERSION", None); + let pcre_version = platform_info.get_constant("PCRE_VERSION", None); let pcre_version_str = match &pcre_version { PhpMixed::String(s) => s.clone(), _ => "".to_string(), @@ -1095,13 +1062,13 @@ impl PlatformRepository { Preg::replace(php_regex!("{^(\\S+).*}"), "$1", &pcre_version_str); self.add_library(&mut libraries, name, Some(&stripped), None, &[], &[])?; - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // PCRE Unicode Version => 12.1.0 let mut pcre_unicode_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^PCRE Unicode Version => (?.+)$/im"), - &info, + info, Some(&mut pcre_unicode_matches), ) { self.add_library( @@ -1118,14 +1085,14 @@ impl PlatformRepository { } "mysqlnd" | "pdo_mysql" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!( "/^(?:Client API version|Version) => mysqlnd (?.+?) /mi" ), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1142,12 +1109,12 @@ impl PlatformRepository { } "mongodb" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut libmongoc_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libmongoc bundled version => (?.+)$/im"), - &info, + info, Some(&mut libmongoc_matches), ) { self.add_library( @@ -1165,7 +1132,7 @@ impl PlatformRepository { let mut libbson_matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libbson bundled version => (?.+)$/im"), - &info, + info, Some(&mut libbson_matches), ) { self.add_library( @@ -1182,8 +1149,8 @@ impl PlatformRepository { } "pgsql" => { - if self.runtime.has_constant("PGSQL_LIBPQ_VERSION", None) { - let pq_version = self.runtime.get_constant("PGSQL_LIBPQ_VERSION", None); + if platform_info.has_constant("PGSQL_LIBPQ_VERSION", None) { + let pq_version = platform_info.get_constant("PGSQL_LIBPQ_VERSION", None); let pq_version_str = match &pq_version { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -1198,12 +1165,12 @@ impl PlatformRepository { )?; } else { // intentional fall-through to next case... - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^PostgreSQL\\(libpq\\) Version => (?.*)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1221,12 +1188,12 @@ impl PlatformRepository { } "pdo_pgsql" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^PostgreSQL\\(libpq\\) Version => (?.*)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1243,14 +1210,14 @@ impl PlatformRepository { } "pq" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); // Used Library => Compiled => Linked // libpq => 14.3 (Ubuntu 14.3-1.pgdg22.04+1) => 15.0.2 let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libpq => (?.+) => (?.+)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1267,7 +1234,7 @@ impl PlatformRepository { } "rdkafka" => { - if self.runtime.has_constant("RD_KAFKA_VERSION", None) { + if platform_info.has_constant("RD_KAFKA_VERSION", None) { // Interpreted as hex MM.mm.rr.xx: // - MM = Major // - mm = minor @@ -1275,8 +1242,7 @@ impl PlatformRepository { // - xx = pre-release id (0xff is the final release) // // pre-release ID in practice is always 0xff even for RCs etc, so we ignore it - let lib_rd_kafka_version_int = self - .runtime + let lib_rd_kafka_version_int = platform_info .get_constant("RD_KAFKA_VERSION", None) .as_int() .unwrap_or(0); @@ -1298,8 +1264,8 @@ impl PlatformRepository { } "libsodium" | "sodium" => { - if self.runtime.has_constant("SODIUM_LIBRARY_VERSION", None) { - let sodium = self.runtime.get_constant("SODIUM_LIBRARY_VERSION", None); + if platform_info.has_constant("SODIUM_LIBRARY_VERSION", None) { + let sodium = platform_info.get_constant("SODIUM_LIBRARY_VERSION", None); let sodium_str = match &sodium { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -1324,12 +1290,12 @@ impl PlatformRepository { } "sqlite3" | "pdo_sqlite" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^SQLite Library => (?.+)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1346,12 +1312,12 @@ impl PlatformRepository { } "ssh2" => { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^libssh2 version => (?.+)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1368,7 +1334,8 @@ impl PlatformRepository { } "xsl" => { - let libxslt_version = self.runtime.get_constant("LIBXSLT_DOTTED_VERSION", None); + let libxslt_version = + platform_info.get_constant("LIBXSLT_DOTTED_VERSION", None); let libxslt_str = match &libxslt_version { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -1382,13 +1349,13 @@ impl PlatformRepository { &[], )?; - let info = self.runtime.get_extension_info("xsl")?; + let info = platform_info.get_extension_info("xsl"); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!( "/^libxslt compiled against libxml Version => (?.+)$/im" ), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1405,12 +1372,12 @@ impl PlatformRepository { } "yaml" => { - let info = self.runtime.get_extension_info("yaml")?; + let info = platform_info.get_extension_info("yaml"); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^LibYAML Version => (?.+)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1427,13 +1394,9 @@ impl PlatformRepository { } "zip" => { - if self - .runtime - .has_constant("LIBZIP_VERSION", Some("ZipArchive".to_string())) - { - let libzip = self - .runtime - .get_constant("LIBZIP_VERSION", Some("ZipArchive".to_string())); + if platform_info.has_constant("LIBZIP_VERSION", Some("ZipArchive")) { + let libzip = + platform_info.get_constant("LIBZIP_VERSION", Some("ZipArchive")); let libzip_str = match &libzip { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -1450,8 +1413,8 @@ impl PlatformRepository { } "zlib" => { - if self.runtime.has_constant("ZLIB_VERSION", None) { - let zlib = self.runtime.get_constant("ZLIB_VERSION", None); + if platform_info.has_constant("ZLIB_VERSION", None) { + let zlib = platform_info.get_constant("ZLIB_VERSION", None); let zlib_str = match &zlib { PhpMixed::String(s) => Some(s.clone()), _ => None, @@ -1467,11 +1430,11 @@ impl PlatformRepository { // Linked Version => 1.2.8 } else { - let info = self.runtime.get_extension_info(name)?; + let info = platform_info.get_extension_info(name); let mut matches: IndexMap = IndexMap::new(); if Preg::is_match3( php_regex!("/^Linked Version => (?.+)$/im"), - &info, + info, Some(&mut matches), ) { self.add_library( @@ -1840,7 +1803,7 @@ impl PlatformRepository { } /// PHP `$resourceBundle->get($key)`. A live PHP object has no `PhpMixed` counterpart, so - /// [`RuntimeInterface`] answers with the entries the caller reads instead of the object. + /// [`PlatformInfo`] carries the entries the caller reads instead of the object. fn resource_bundle_get(value: &PhpMixed, key: &str) -> PhpMixed { Self::php_object_field(value, key).unwrap_or(PhpMixed::Null) } diff --git a/crates/shirabe/tests/package/version/version_selector_test.rs b/crates/shirabe/tests/package/version/version_selector_test.rs index 13544399..16ad5fe2 100644 --- a/crates/shirabe/tests/package/version/version_selector_test.rs +++ b/crates/shirabe/tests/package/version/version_selector_test.rs @@ -120,7 +120,7 @@ fn test_latest_version_is_returned_that_matches_php_requirements() { let mut overrides: IndexMap = IndexMap::new(); overrides.insert("php".to_string(), PhpMixed::String("5.5.0".to_string())); - let mut platform = PlatformRepository::new(vec![], overrides).unwrap(); + let mut platform = PlatformRepository::new(vec![], overrides, None, None).unwrap(); let package0 = get_package("foo/bar", "0.9.0"); package0.__set_requires(IndexMap::from([( @@ -216,7 +216,7 @@ fn test_latest_version_is_returned_that_matches_ext_requirements() { let mut overrides: IndexMap = IndexMap::new(); overrides.insert("ext-zip".to_string(), PhpMixed::String("5.3.0".to_string())); - let mut platform = PlatformRepository::new(vec![], overrides).unwrap(); + let mut platform = PlatformRepository::new(vec![], overrides, None, None).unwrap(); let package1 = get_package("foo/bar", "1.0.0"); package1.__set_requires(IndexMap::from([( @@ -263,7 +263,7 @@ fn test_latest_version_is_returned_that_matches_ext_requirements() { fn test_latest_version_is_returned_that_matches_platform_ext() { let package_name = "foo/bar"; - let mut platform = PlatformRepository::new(vec![], IndexMap::new()).unwrap(); + let mut platform = PlatformRepository::new(vec![], IndexMap::new(), None, None).unwrap(); let package1 = get_package("foo/bar", "1.0.0"); let package2 = get_package("foo/bar", "2.0.0"); @@ -311,7 +311,7 @@ fn test_latest_version_is_returned_that_matches_composer_requirements() { "composer-runtime-api".to_string(), PhpMixed::String("1.0.0".to_string()), ); - let mut platform = PlatformRepository::new(vec![], overrides).unwrap(); + let mut platform = PlatformRepository::new(vec![], overrides, None, None).unwrap(); let package1 = get_package("foo/bar", "1.0.0"); package1.__set_requires(IndexMap::from([( diff --git a/crates/shirabe/tests/platform/main.rs b/crates/shirabe/tests/platform/main.rs index 936049ac..3cf2cf48 100644 --- a/crates/shirabe/tests/platform/main.rs +++ b/crates/shirabe/tests/platform/main.rs @@ -1,3 +1,2 @@ mod hhvm_detector_test; -mod runtime_test; mod version_test; diff --git a/crates/shirabe/tests/platform/runtime_test.rs b/crates/shirabe/tests/platform/runtime_test.rs deleted file mode 100644 index ebdeaffa..00000000 --- a/crates/shirabe/tests/platform/runtime_test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! ref: composer/tests/Composer/Test/Platform/RuntimeTest.php - -use shirabe::platform::runtime::Runtime; - -#[test] -fn test_parse_extension_info() { - for (html_input, expected_output) in provide_extension_infos() { - assert_eq!( - expected_output, - Runtime::parse_html_extension_info(html_input) - ); - } -} - -fn provide_extension_infos() -> Vec<(&'static str, &'static str)> { - vec![( - // 'pdo_sqlite' - "

pdo_sqlite

- - - -
PDO Driver for SQLite 3.x enabled
SQLite Library 3.40.1
", - "pdo_sqlite - -PDO Driver for SQLite 3.x => enabled -SQLite Library => 3.40.1", - )] -} diff --git a/crates/shirabe/tests/repository/platform_repository_test.rs b/crates/shirabe/tests/repository/platform_repository_test.rs index 3a4bf45d..573f96ed 100644 --- a/crates/shirabe/tests/repository/platform_repository_test.rs +++ b/crates/shirabe/tests/repository/platform_repository_test.rs @@ -1,31 +1,15 @@ //! ref: composer/tests/Composer/Test/Repository/PlatformRepositoryTest.php use indexmap::IndexMap; -use mockall::predicate::eq; use shirabe::package::{BasePackageHandle, Link}; -use shirabe::platform::{HhvmDetectorInterface, RuntimeInterface}; +use shirabe::platform::HhvmDetectorInterface; use shirabe::repository::{ FindPackageConstraint, PlatformRepository, RepositoryInterface, SEARCH_NAME, }; +use shirabe_php_rpc::PlatformInfo; use shirabe_php_shim::PhpMixed; use shirabe_semver::constraint::SimpleConstraint; -// The Runtime/HhvmDetector seams are concrete structs in PHP; the tests mock them -// directly. -mockall::mock! { - pub Runtime {} - impl RuntimeInterface for Runtime { - fn has_constant(&self, constant_name: &str, class: Option) -> bool; - fn get_constant(&self, constant_name: &str, class: Option) -> PhpMixed; - fn invoke(&self, callable: PhpMixed, arguments: Vec) -> PhpMixed; - fn has_class(&self, class: &str) -> bool; - fn construct(&self, class: &str, arguments: Vec) -> anyhow::Result; - fn get_extensions(&self) -> Vec; - fn get_extension_version(&self, extension: &str) -> String; - fn get_extension_info(&self, extension: &str) -> anyhow::Result; - } -} - mockall::mock! { pub HhvmDetector {} impl HhvmDetectorInterface for HhvmDetector { @@ -34,25 +18,71 @@ mockall::mock! { } } -// The seam traits require `Debug` (so `PlatformRepository` can derive it); mockall does +// The seam trait requires `Debug` (so `PlatformRepository` can derive it); mockall does // not generate it for mocks. -impl std::fmt::Debug for MockRuntime { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("MockRuntime") - } -} - impl std::fmt::Debug for MockHhvmDetector { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("MockHhvmDetector") } } -/// PHP: ltrim($class.'::'.$constant, ':') -fn constant_key(constant_name: &str, class: Option<&str>) -> String { - format!("{}::{}", class.unwrap_or(""), constant_name) - .trim_start_matches(':') - .to_string() +/// The payload form of what a dataset's `Runtime` mock answers: the listed constants and classes +/// exist, and each extension is loaded at `extension_version` with `info` as its info() output. +fn platform_info( + constants: Vec<(String, Option, PhpMixed)>, + extensions: Vec, + extension_version: &str, + info: Option<&str>, + functions: &[(PhpMixed, Vec, PhpMixed)], + class_definitions: &[ClassDef], +) -> PlatformInfo { + let mut platform_info = PlatformInfo::default(); + + for (constant_name, class, value) in constants { + platform_info.__set_constant(&constant_name, class.as_deref(), value); + } + + for extension in &extensions { + platform_info.__set_extension_info(extension, info.unwrap_or_default()); + } + platform_info.__set_extensions(extensions, extension_version); + + for (callable, arguments, result) in functions { + match (callable, arguments.as_slice()) { + (PhpMixed::String(name), []) if name == "curl_version" => { + platform_info.curl_version = result.clone(); + } + (PhpMixed::String(name), [PhpMixed::String(address)]) + if name == "inet_pton" && address == "::" => + { + platform_info.inet_pton_ipv6 = result.clone(); + } + (PhpMixed::List(spec), _) => match spec.as_slice() { + [PhpMixed::String(class), PhpMixed::String(method)] + if class == "ResourceBundle" && method == "create" => + { + platform_info.resource_bundle = result.clone(); + } + [PhpMixed::String(class), PhpMixed::String(method)] + if class == "IntlChar" && method == "getUnicodeVersion" => + { + platform_info.intl_char_unicode_version = result.clone(); + } + other => panic!("the platform payload does not report {other:?}"), + }, + other => panic!("the platform payload does not report {other:?}"), + } + } + + for definition in class_definitions { + match (definition.class, &definition.construct) { + ("Imagick", Some((_arguments, result))) => platform_info.imagick = result.clone(), + (class, None) => platform_info.__set_class(class), + (class, Some(_)) => panic!("the platform payload does not construct {class}"), + } + } + + platform_info } #[test] @@ -64,7 +94,7 @@ fn test_hhvm_package() { .returning(|| Some("2.1.0".to_string())); let mut platform_repository = - PlatformRepository::new4(vec![], IndexMap::new(), None, Some(Box::new(hhvm_detector))) + PlatformRepository::new(vec![], IndexMap::new(), None, Some(Box::new(hhvm_detector))) .unwrap(); let hhvm = platform_repository @@ -137,44 +167,20 @@ fn php_flavor_test_cases() -> Vec<( #[test] fn test_php_version() { for (constants, packages, functions) in php_flavor_test_cases() { - let constants_has = constants.clone(); - let constants_get = constants.clone(); - - let mut runtime = MockRuntime::new(); - runtime - .expect_get_extensions() - .times(..) - .returning(Vec::new); - runtime - .expect_has_constant() - .times(..) - .returning(move |constant, class| { - constants_has.contains_key(&constant_key(constant, class.as_deref())) - }); - runtime - .expect_get_constant() - .times(..) - .returning(move |constant, class| { - constants_get - .get(&constant_key(constant, class.as_deref())) - .cloned() - .unwrap_or(PhpMixed::Null) - }); - runtime - .expect_invoke() - .times(..) - .returning(move |callable, arguments| { - for (c, a, ret) in &functions { - if *c == callable && *a == arguments { - return ret.clone(); - } - } - PhpMixed::Null - }); + let platform_info = platform_info( + constants + .into_iter() + .map(|(constant_name, value)| (constant_name, None, value)) + .collect(), + Vec::new(), + "", + None, + &functions, + &[], + ); let mut repository = - PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None) - .unwrap(); + PlatformRepository::new(vec![], IndexMap::new(), Some(platform_info), None).unwrap(); for (package_name, version) in packages { let package = repository @@ -198,22 +204,16 @@ fn test_php_version() { #[test] fn test_inet_pton_regression() { - let mut runtime = MockRuntime::new(); // PHP: ->expects(self::once())->method('invoke')->with('inet_pton', ['::'])->willReturn(false). - runtime - .expect_invoke() - .with( - eq(PhpMixed::String("inet_pton".to_string())), - eq(vec![PhpMixed::String("::".to_string())]), - ) - .times(1) - .returning(|_callable, _arguments| PhpMixed::Bool(false)); - // suppressing PHP_ZTS & AF_INET6 - runtime - .expect_has_constant() - .times(..) - .returning(|_, _| false); - + // TODO(phase-d): the payload reports the result of `@inet_pton('::')` instead of answering a + // call, so there is nothing left for the once() call-count check to observe. + let functions = [( + PhpMixed::String("inet_pton".to_string()), + vec![PhpMixed::String("::".to_string())], + PhpMixed::Bool(false), + )]; + + // suppressing PHP_ZTS & AF_INET6 by leaving them undefined let constants: IndexMap = IndexMap::from([ ( "PHP_VERSION".to_string(), @@ -221,22 +221,21 @@ fn test_inet_pton_regression() { ), ("PHP_DEBUG".to_string(), PhpMixed::Bool(false)), ]); - runtime - .expect_get_constant() - .times(..) - .returning(move |constant, class| { - constants - .get(&constant_key(constant, class.as_deref())) - .cloned() - .unwrap_or(PhpMixed::Null) - }); - runtime - .expect_get_extensions() - .times(..) - .returning(Vec::new); + + let platform_info = platform_info( + constants + .into_iter() + .map(|(constant_name, value)| (constant_name, None, value)) + .collect(), + Vec::new(), + "", + None, + &functions, + &[], + ); let mut repository = - PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None).unwrap(); + PlatformRepository::new(vec![], IndexMap::new(), Some(platform_info), None).unwrap(); let package = repository .find_package("php-ipv6", FindPackageConstraint::String("*".to_string())) .unwrap(); @@ -1630,79 +1629,17 @@ fn test_library_information() { PhpMixed::String("7.1.0".to_string()), )); - let functions = case.functions.clone(); - let info = case.info.map(|s| s.to_string()); - - let exts_for_get = extensions.clone(); - let constants_has = constants.clone(); - let constants_get = constants.clone(); - - let mut runtime = MockRuntime::new(); - runtime - .expect_get_extensions() - .times(..) - .returning(move || exts_for_get.clone()); - runtime - .expect_get_extension_version() - .times(..) - .returning(move |_extension| extension_version.to_string()); - runtime - .expect_get_extension_info() - .times(..) - .returning(move |_extension| Ok(info.clone().unwrap_or_default())); - runtime - .expect_invoke() - .times(..) - .returning(move |callable, arguments| { - for (c, a, ret) in &functions { - if *c == callable && *a == arguments { - return ret.clone(); - } - } - PhpMixed::Null - }); - runtime - .expect_has_constant() - .times(..) - .returning(move |constant, class| { - constants_has - .iter() - .any(|(n, c, _)| n == constant && c.as_deref() == class.as_deref()) - }); - runtime - .expect_get_constant() - .times(..) - .returning(move |constant, class| { - constants_get - .iter() - .find(|(n, c, _)| n == constant && c.as_deref() == class.as_deref()) - .map(|(_, _, v)| v.clone()) - .unwrap_or(PhpMixed::Null) - }); - let class_definitions_has = case.class_definitions.clone(); - let class_definitions_construct = case.class_definitions.clone(); - runtime - .expect_has_class() - .times(..) - .returning(move |class| class_definitions_has.iter().any(|d| d.class == class)); - runtime - .expect_construct() - .times(..) - .returning(move |class, arguments| { - for d in &class_definitions_construct { - if d.class == class - && let Some((args, ret)) = &d.construct - && *args == arguments - { - return Ok(ret.clone()); - } - } - Ok(PhpMixed::Null) - }); + let platform_info = platform_info( + constants, + extensions.clone(), + extension_version, + case.info, + &case.functions, + &case.class_definitions, + ); let mut platform_repository = - PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None) - .unwrap(); + PlatformRepository::new(vec![], IndexMap::new(), Some(platform_info), None).unwrap(); let libraries: Vec = platform_repository .search("lib".to_string(), SEARCH_NAME, None) @@ -1794,33 +1731,22 @@ fn test_composer_platform_version() { ("PHP_DEBUG".to_string(), PhpMixed::Bool(false)), ]); - let mut runtime = MockRuntime::new(); - runtime - .expect_get_extensions() - .times(..) - .returning(Vec::new); - runtime - .expect_get_constant() - .times(..) - .returning(move |constant, class| { - constants - .get(&constant_key(constant, class.as_deref())) - .cloned() - .unwrap_or(PhpMixed::Null) - }); // PHP only stubs getExtensions/getConstant; PHPUnit auto-returns null/false for the - // other probed methods. Mirror that so initialize() does not hit unset expectations. - runtime - .expect_has_constant() - .times(..) - .returning(|_, _| false); - runtime - .expect_invoke() - .times(..) - .returning(|_, _| PhpMixed::Null); + // other methods, which is what the default payload reports. + let platform_info = platform_info( + constants + .into_iter() + .map(|(constant_name, value)| (constant_name, None, value)) + .collect(), + Vec::new(), + "", + None, + &[], + &[], + ); let mut platform_repository = - PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None).unwrap(); + PlatformRepository::new(vec![], IndexMap::new(), Some(platform_info), None).unwrap(); let package = platform_repository .find_package( diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index 392b318e..c833bbea 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -5,9 +5,7 @@ and its plugin/script machinery executes real PHP code. To mimic this behavior n runtime. The `shirabe-php-rpc` crate spawns the system PHP as a child process and talks to it over a Unix -domain socket. There is exactly one child process per Shirabe process, shared by every caller; -it hosts both the simple runtime queries (`get_php_version`, `has_constant`, ...) and the plugin -protocol. +domain socket. There is exactly one child process per Shirabe process, shared by every caller. ## Locating PHP @@ -129,9 +127,17 @@ same dispatch while waiting for its own `Return`. ## Worker dispatch table -`CallFunction` first consults the worker's dispatch table (composite queries like `diagnose`, -Shirabe-internal helpers prefixed `__shirabe_`), then falls back to calling the named PHP -function; an unknown name is an explicit error. Notable internal helpers: +`CallFunction` first consults the worker's dispatch table (composite queries like `diagnose` and +`platform`, Shirabe-internal helpers prefixed `__shirabe_`), then falls back to calling the named +PHP function; an unknown name is an explicit error. + +A composite query answers everything one consumer needs about the runtime in a single round trip, +because asking one constant and one extension at a time costs a round trip each. The Rust side +decodes the answer into a struct cached in a `OnceLock` (`Diagnostics` for `diagnose`, +`PlatformInfo` for `platform`) whose accessors panic on a name the worker does not report, so a +consumer and the worker cannot drift apart unnoticed. + +Notable internal helpers: - `__shirabe_eval` — runs a Rust-generated PHP snippet and returns its `return` value (used by the `scripts` Command-class execution path and the `_composer_tmp` class-rename path of -- cgit v1.3.1