aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-07 20:51:59 +0900
committernsfisis <nsfisis@gmail.com>2026-08-07 20:51:59 +0900
commitae365645730b95d5b01b0df7382b91668d5fa24e (patch)
tree962e3abb51132097ff11a79d453e058945ae699c /crates/shirabe-php-rpc
parentf749a47804cd296a3059cd3f8079c62dbaa5fdc0 (diff)
downloadphp-shirabe-ae365645730b95d5b01b0df7382b91668d5fa24e.tar.gz
php-shirabe-ae365645730b95d5b01b0df7382b91668d5fa24e.tar.zst
php-shirabe-ae365645730b95d5b01b0df7382b91668d5fa24e.zip
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) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-rpc')
-rw-r--r--crates/shirabe-php-rpc/php/worker.php134
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs522
2 files changed, 516 insertions, 140 deletions
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<String> {
- 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<String>,
+ extension_versions: IndexMap<String, String>,
+ extension_info: IndexMap<String, String>,
+ /// Keyed as `ltrim($class.'::'.$constant, ':')`; `None` for a reported but undefined constant.
+ constants: IndexMap<String, Option<PhpMixed>>,
+ classes: IndexMap<String, bool>,
+ /// `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<String>, 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<PlatformInfo> = 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::<Vec<_>>(),
+ 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::<Vec<_>>(),
+ 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<String, PhpMixed>, 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<String, PhpMixed>,
}
-fn string_field(payload: &IndexMap<String, PhpMixed>, 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<String, PhpMixed>, key: &str) -> Option<String> {
- as_nullable_string(field(payload, key), key)
-}
+ fn mixed(&self, key: &str) -> PhpMixed {
+ self.field(key).clone()
+ }
-fn int_field(payload: &IndexMap<String, PhpMixed>, 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 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 bool_field(payload: &IndexMap<String, PhpMixed>, key: &str) -> bool {
- as_bool(field(payload, key), key)
-}
+ fn nullable_string(&self, key: &str) -> Option<String> {
+ as_nullable_string(self.field(key), self.what, key)
+ }
-fn map_field<'a>(
- payload: &'a IndexMap<String, PhpMixed>,
- key: &str,
-) -> &'a IndexMap<String, PhpMixed> {
- match field(payload, key) {
- PhpMixed::Array(map) => map,
- other => panic!("PHP RPC: `diagnose` payload entry `{key}` is not an array: {other:?}"),
+ 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 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<String, PhpMixed>> {
+ 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<Item = (&String, &PhpMixed)> {
+ self.map(key).into_iter().flatten()
+ }
+
+ fn bool_map(&self, key: &str) -> IndexMap<String, bool> {
+ self.entries(key)
+ .map(|(name, value)| (name.clone(), as_bool(value, self.what, name)))
+ .collect()
+ }
+
+ fn string_map(&self, key: &str) -> IndexMap<String, String> {
+ 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<String> {
+ 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<String> {
+ 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<String, PhpMixed>, key: &str) -> Option<i64> {
- match field(payload, key) {
+fn nullable_int_field(payload: &Payload, key: &str) -> Option<i64> {
+ 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<String, PhpMixed>, key: &str) -> Option<Curl> {
- let curl = match field(payload, key) {
+fn curl_field(payload: &Payload, key: &str) -> Option<Curl> {
+ 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<String> {
- 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<String> {
match call("phpversion", extension) {
@@ -281,11 +536,6 @@ pub fn phpversion(extension: &str) -> Option<String> {
}
}
-/// PHP `get_loaded_extensions()`.
-pub fn get_loaded_extensions() -> Vec<String> {
- 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);
+ }
}