From 90764c477f24e4d67d3e0cae943c33fd2b8ae220 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 6 Aug 2026 02:40:00 +0900 Subject: feat(plugin): widen the RPC surface to LibraryInstaller-based plugins An installer that extends LibraryInstaller reaches for the Composer object graph in ways the proxy did not answer: the download manager and the config, the writable repository methods, a React promise as its own return value, and `new Package(...)` from its supports() path. * `Composer::getConfig`/`getDownloadManager` are dispatched, and both classes become generated proxy stubs. Their reads and mutators answer from the Rust entity; the surfaces needing stubs of their own (ConfigSourceInterface, DownloaderInterface) stay explicit errors. * The download manager's futures are driven to completion and handed back as already-settled React promises, since PHP declares a non-nullable PromiseInterface there. A promise a plugin returns is drained the same way: settled yields its value, rejected re-raises, pending is an explicit error. * Proxy stubs now carry the real class's constructor and ask the Rust side to allocate the entity; reviving a stub for an existing entity binds the handle without running it. Classes Rust cannot build name themselves in the error. * The package proxy covers `Package`'s own setters, `CompletePackage`'s metadata, `RootPackage`'s root-only state, and `BasePackage::$id`. Link values and release dates still have no wire image, so the methods carrying them remain explicit errors. --- .../php/runtime/Composer/EventDispatcher/Event.php | 30 +- .../shirabe-php-rpc/php/stubs/Composer/Config.php | 212 +++++ .../stubs/Composer/Downloader/DownloadManager.php | 128 +++ .../Composer/EventDispatcher/EventDispatcher.php | 23 +- .../php/stubs/Composer/IO/BaseIO.php | 22 +- .../php/stubs/Composer/IO/BufferIO.php | 9 + .../php/stubs/Composer/IO/ConsoleIO.php | 9 + .../Composer/Installer/InstallationManager.php | 22 +- .../php/stubs/Composer/Package/BasePackage.php | 22 +- .../php/stubs/Composer/Package/Package.php | 6 + .../php/stubs/Composer/PartialComposer.php | 22 +- .../stubs/Composer/Repository/ArrayRepository.php | 22 +- .../Composer/Repository/FilesystemRepository.php | 9 + .../Composer/Repository/RepositoryManager.php | 25 +- .../php/stubs/Composer/Script/Event.php | 6 + crates/shirabe-php-rpc/php/worker.php | 55 +- crates/shirabe-php-rpc/src/lib.rs | 8 + .../src/event_dispatcher/event_dispatcher.rs | 3 + crates/shirabe/src/package/handle.rs | 5 +- crates/shirabe/src/plugin/php_plugin_proxy.rs | 895 ++++++++++++++++++++- 20 files changed, 1407 insertions(+), 126 deletions(-) create mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/Config.php create mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/Downloader/DownloadManager.php (limited to 'crates') diff --git a/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php index 0d35ac55..e2c63439 100644 --- a/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php +++ b/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php @@ -4,9 +4,10 @@ // lives in both worlds at once: an instance revived from a Rust handle proxies every call over // RPC (like a generated stub), while an instance constructed natively — real Composer code in // this process does `new PreCommandRunEvent(...)`, whose parent constructor lands here — is a -// faithful in-process port of the real base class. The two modes are told apart by the -// constructor arguments; `__shirabeRustHandleDescriptor()` returns null in native mode so the -// wire codec registers the object in the P table instead of treating it as a Rust handle. +// faithful in-process port of the real base class. Proxy revival binds the handle without +// running the constructor, so the constructor below is the native mode alone; +// `__shirabeRustHandleDescriptor()` returns null there so the wire codec registers the object in +// the P table instead of treating it as a Rust handle. namespace Composer\EventDispatcher; @@ -29,29 +30,20 @@ class Event implements \ShirabeRustStub /** @var bool Whether the event should not be passed to more listeners (native mode) */ private $propagationStopped = false; - /** - * Proxy revival passes (int $rhandle, int $epoch); the real class's constructor is - * (string $name, array $args = [], array $flags = []). - */ - public function __construct($name = null, $args = [], $flags = []) + public function __construct(string $name, array $args = [], array $flags = []) { - if (is_int($name) && func_num_args() === 2 && is_int($args)) { - $this->__rhandle = $name; - $this->__epoch = $args; - - return; - } - if (!is_string($name)) { - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process without an event name' - ); - } $this->__rhandle = null; $this->name = $name; $this->args = $args; $this->flags = $flags; } + public function __shirabeBind(int $rhandle, int $epoch): void + { + $this->__rhandle = $rhandle; + $this->__epoch = $epoch; + } + public function __destruct() { if ($this->__rhandle !== null) { diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Config.php b/crates/shirabe-php-rpc/php/stubs/Composer/Config.php new file mode 100644 index 00000000..bccb8dcb --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Config.php @@ -0,0 +1,212 @@ +__rhandle = $rhandle; + $this->__epoch = $epoch; + } + + public function __destruct() + { + \ShirabeRustObjectRegistry::release($this->__rhandle); + } + + public function __shirabeRustHandleDescriptor(): array + { + return [ + '__rhandle' => $this->__rhandle, + '__class' => static::class, + '__epoch' => $this->__epoch, + ]; + } + + public function __clone() + { + // PHP has already shallow-copied this stub, so both copies would point at one + // entity and release it twice. The Rust side clones the entity instead, applying + // whatever __clone semantics the real class defines, and this copy rebinds to the + // fresh handle. Entities without clone semantics answer with an explicit error. + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust($this->__rhandle, '__shirabeClone', []); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function __construct(bool $useEnvironment = true, ?string $baseDir = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$useEnvironment, $baseDir]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public const SOURCE_DEFAULT = 'default'; + public const SOURCE_COMMAND = 'command'; + public const SOURCE_UNKNOWN = 'unknown'; + public const RELATIVE_PATHS = 1; + + public static $defaultConfig = [ + 'process-timeout' => 300, + 'use-include-path' => false, + 'allow-plugins' => [], + 'use-parent-dir' => 'prompt', + 'preferred-install' => 'dist', + 'audit' => ['ignore' => [], 'abandoned' => Auditor::ABANDONED_FAIL], + 'notify-on-install' => true, + 'github-protocols' => ['https', 'ssh', 'git'], + 'gitlab-protocol' => null, + 'vendor-dir' => 'vendor', + 'bin-dir' => '{$vendor-dir}/bin', + 'cache-dir' => '{$home}/cache', + 'data-dir' => '{$home}', + 'cache-files-dir' => '{$cache-dir}/files', + 'cache-repo-dir' => '{$cache-dir}/repo', + 'cache-vcs-dir' => '{$cache-dir}/vcs', + 'cache-ttl' => 15552000, // 6 months + 'cache-files-ttl' => null, // fallback to cache-ttl + 'cache-files-maxsize' => '300MiB', + 'cache-read-only' => false, + 'bin-compat' => 'auto', + 'discard-changes' => false, + 'autoloader-suffix' => null, + 'sort-packages' => false, + 'optimize-autoloader' => false, + 'classmap-authoritative' => false, + 'apcu-autoloader' => false, + 'prepend-autoloader' => true, + 'update-with-minimal-changes' => false, + 'github-domains' => ['github.com'], + 'bitbucket-expose-hostname' => true, + 'disable-tls' => false, + 'secure-http' => true, + 'secure-svn-domains' => [], + 'cafile' => null, + 'capath' => null, + 'github-expose-hostname' => true, + 'gitlab-domains' => ['gitlab.com'], + 'store-auths' => 'prompt', + 'platform' => [], + 'archive-format' => 'tar', + 'archive-dir' => '.', + 'htaccess-protect' => true, + 'use-github-api' => true, + 'lock' => true, + 'platform-check' => 'php-only', + 'bitbucket-oauth' => [], + 'github-oauth' => [], + 'gitlab-oauth' => [], + 'gitlab-token' => [], + 'http-basic' => [], + 'bearer' => [], + 'custom-headers' => [], + 'bump-after-update' => false, + 'allow-missing-requirements' => false, + 'client-certificate' => [], + 'forgejo-domains' => ['codeberg.org'], + 'forgejo-token' => [], + ]; + public static $defaultRepositories = [ + 'packagist.org' => [ + 'type' => 'composer', + 'url' => 'https://repo.packagist.org', + ], + ]; + + public static function disableProcessTimeout(): void + { + // Override global timeout set earlier by environment or config + ProcessExecutor::setTimeout(0); + } + + public function setBaseDir(?string $baseDir): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setBaseDir', [$baseDir]); + } + + public function setConfigSource(ConfigSourceInterface $source): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setConfigSource', [$source]); + } + + public function getConfigSource(): ConfigSourceInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getConfigSource', []); + } + + public function setAuthConfigSource(ConfigSourceInterface $source): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setAuthConfigSource', [$source]); + } + + public function getAuthConfigSource(): ConfigSourceInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getAuthConfigSource', []); + } + + public function setLocalAuthConfigSource(ConfigSourceInterface $source): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setLocalAuthConfigSource', [$source]); + } + + public function getLocalAuthConfigSource(): ?ConfigSourceInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getLocalAuthConfigSource', []); + } + + public function merge(array $config, string $source = self::SOURCE_UNKNOWN): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'merge', [$config, $source]); + } + + public function getRepositories(): array + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getRepositories', []); + } + + public function get(string $key, int $flags = 0) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'get', [$key, $flags]); + } + + public function all(int $flags = 0): array + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'all', [$flags]); + } + + public function getSourceOfValue(string $key): string + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getSourceOfValue', [$key]); + } + + public function raw(): array + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'raw', []); + } + + public function has(string $key): bool + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'has', [$key]); + } + + public function prohibitUrlByConfig(string $url, ?IOInterface $io = null, array $repoOptions = []): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'prohibitUrlByConfig', [$url, $io, $repoOptions]); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Downloader/DownloadManager.php b/crates/shirabe-php-rpc/php/stubs/Composer/Downloader/DownloadManager.php new file mode 100644 index 00000000..3828ac4a --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Downloader/DownloadManager.php @@ -0,0 +1,128 @@ +__rhandle = $rhandle; + $this->__epoch = $epoch; + } + + public function __destruct() + { + \ShirabeRustObjectRegistry::release($this->__rhandle); + } + + public function __shirabeRustHandleDescriptor(): array + { + return [ + '__rhandle' => $this->__rhandle, + '__class' => static::class, + '__epoch' => $this->__epoch, + ]; + } + + public function __clone() + { + // PHP has already shallow-copied this stub, so both copies would point at one + // entity and release it twice. The Rust side clones the entity instead, applying + // whatever __clone semantics the real class defines, and this copy rebinds to the + // fresh handle. Entities without clone semantics answer with an explicit error. + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust($this->__rhandle, '__shirabeClone', []); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function __construct(IOInterface $io, bool $preferSource = false, ?Filesystem $filesystem = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$io, $preferSource, $filesystem]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function setPreferSource(bool $preferSource): self + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setPreferSource', [$preferSource]); + return $this; + } + + public function setPreferDist(bool $preferDist): self + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setPreferDist', [$preferDist]); + return $this; + } + + public function setPreferences(array $preferences): self + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setPreferences', [$preferences]); + return $this; + } + + public function setDownloader(string $type, DownloaderInterface $downloader): self + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setDownloader', [$type, $downloader]); + return $this; + } + + public function getDownloader(string $type): DownloaderInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getDownloader', [$type]); + } + + public function getDownloaderForPackage(PackageInterface $package): ?DownloaderInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getDownloaderForPackage', [$package]); + } + + public function getDownloaderType(DownloaderInterface $downloader): string + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getDownloaderType', [$downloader]); + } + + public function download(PackageInterface $package, string $targetDir, ?PackageInterface $prevPackage = null): PromiseInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'download', [$package, $targetDir, $prevPackage]); + } + + public function prepare(string $type, PackageInterface $package, string $targetDir, ?PackageInterface $prevPackage = null): PromiseInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'prepare', [$type, $package, $targetDir, $prevPackage]); + } + + public function install(PackageInterface $package, string $targetDir): PromiseInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'install', [$package, $targetDir]); + } + + public function update(PackageInterface $initial, PackageInterface $target, string $targetDir): PromiseInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'update', [$initial, $target, $targetDir]); + } + + public function remove(PackageInterface $package, string $targetDir): PromiseInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'remove', [$package, $targetDir]); + } + + public function cleanup(string $type, PackageInterface $package, string $targetDir, ?PackageInterface $prevPackage = null): PromiseInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'cleanup', [$type, $package, $targetDir, $prevPackage]); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php index 30f8c0e8..e2369ed2 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php @@ -6,8 +6,11 @@ namespace Composer\EventDispatcher; use Composer\DependencyResolver\Transaction; +use Composer\IO\IOInterface; +use Composer\PartialComposer; use Composer\DependencyResolver\Operation\OperationInterface; use Composer\Repository\RepositoryInterface; +use Composer\Util\ProcessExecutor; class EventDispatcher implements \ShirabeRustStub { @@ -16,16 +19,12 @@ class EventDispatcher implements \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle = 0, int $epoch = 0) + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void { - if (func_num_args() < 2) { - // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`) - // is an open question of the plugin design; only proxy instantiation passes a - // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError. - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet' - ); - } $this->__rhandle = $rhandle; $this->__epoch = $epoch; } @@ -54,6 +53,12 @@ class EventDispatcher implements \ShirabeRustStub \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); } + public function __construct(PartialComposer $composer, IOInterface $io, ?ProcessExecutor $process = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$composer, $io, $process]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function setRunScripts(bool $runScripts = true): self { \ShirabeRpcRuntime::callRust($this->__rhandle, 'setRunScripts', [$runScripts]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php index b3e0fb51..ecb4db2f 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php @@ -14,16 +14,12 @@ abstract class BaseIO implements IOInterface, \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle = 0, int $epoch = 0) - { - if (func_num_args() < 2) { - // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`) - // is an open question of the plugin design; only proxy instantiation passes a - // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError. - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet' - ); - } + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void + { $this->__rhandle = $rhandle; $this->__epoch = $epoch; } @@ -52,6 +48,12 @@ abstract class BaseIO implements IOInterface, \ShirabeRustStub \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); } + public function __construct() + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, []]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function isInteractive() { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isInteractive', []); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php index d5e59e90..b2e84d2b 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php @@ -5,8 +5,17 @@ namespace Composer\IO; +use Symfony\Component\Console\Output\StreamOutput; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; + class BufferIO extends ConsoleIO { + public function __construct(string $input = '', int $verbosity = StreamOutput::VERBOSITY_NORMAL, ?OutputFormatterInterface $formatter = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$input, $verbosity, $formatter]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function getOutput(): string { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOutput', []); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php index 0041363a..b68f50ff 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php @@ -6,10 +6,19 @@ namespace Composer\IO; use Composer\Pcre\Preg; +use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; class ConsoleIO extends BaseIO { + public function __construct(InputInterface $input, OutputInterface $output, HelperSet $helperSet) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$input, $output, $helperSet]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public static function sanitize($messages, bool $allowNewlines = true) { // Match ANSI escape sequences: diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php index d458d30b..7f41f6b9 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/InstallationManager.php @@ -13,6 +13,8 @@ use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Operation\MarkAliasInstalledOperation; use Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation; +use Composer\EventDispatcher\EventDispatcher; +use Composer\Util\Loop; use React\Promise\PromiseInterface; class InstallationManager implements \ShirabeRustStub @@ -22,16 +24,12 @@ class InstallationManager implements \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle = 0, int $epoch = 0) + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void { - if (func_num_args() < 2) { - // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`) - // is an open question of the plugin design; only proxy instantiation passes a - // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError. - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet' - ); - } $this->__rhandle = $rhandle; $this->__epoch = $epoch; } @@ -60,6 +58,12 @@ class InstallationManager implements \ShirabeRustStub \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); } + public function __construct(Loop $loop, IOInterface $io, ?EventDispatcher $eventDispatcher = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$loop, $io, $eventDispatcher]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function reset(): void { \ShirabeRpcRuntime::callRust($this->__rhandle, 'reset', []); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php b/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php index de8037ca..9fca286a 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Package/BasePackage.php @@ -14,16 +14,12 @@ abstract class BasePackage implements PackageInterface, \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle = 0, int $epoch = 0) - { - if (func_num_args() < 2) { - // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`) - // is an open question of the plugin design; only proxy instantiation passes a - // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError. - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet' - ); - } + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void + { $this->__rhandle = $rhandle; $this->__epoch = $epoch; } @@ -52,6 +48,12 @@ abstract class BasePackage implements PackageInterface, \ShirabeRustStub \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); } + public function __construct(string $name) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$name]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public const STABILITY_STABLE = 0; public const STABILITY_RC = 5; public const STABILITY_BETA = 10; diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Package/Package.php b/crates/shirabe-php-rpc/php/stubs/Composer/Package/Package.php index 5b80060d..238dde3b 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Package/Package.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Package/Package.php @@ -7,6 +7,12 @@ namespace Composer\Package; class Package extends BasePackage { + public function __construct(string $name, string $version, string $prettyVersion) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$name, $version, $prettyVersion]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function setType(string $type): void { \ShirabeRpcRuntime::callRust($this->__rhandle, 'setType', [$type]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php index c60edbb2..a48133cb 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php @@ -18,16 +18,12 @@ class PartialComposer implements \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle = 0, int $epoch = 0) - { - if (func_num_args() < 2) { - // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`) - // is an open question of the plugin design; only proxy instantiation passes a - // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError. - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet' - ); - } + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void + { $this->__rhandle = $rhandle; $this->__epoch = $epoch; } @@ -56,6 +52,12 @@ class PartialComposer implements \ShirabeRustStub \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); } + public function __construct() + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, []]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function setPackage(RootPackageInterface $package): void { \ShirabeRpcRuntime::callRust($this->__rhandle, 'setPackage', [$package]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php index 51e6a462..a5358ff2 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/ArrayRepository.php @@ -14,16 +14,12 @@ class ArrayRepository implements RepositoryInterface, \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle = 0, int $epoch = 0) - { - if (func_num_args() < 2) { - // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`) - // is an open question of the plugin design; only proxy instantiation passes a - // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError. - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet' - ); - } + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void + { $this->__rhandle = $rhandle; $this->__epoch = $epoch; } @@ -52,6 +48,12 @@ class ArrayRepository implements RepositoryInterface, \ShirabeRustStub \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); } + public function __construct(array $packages = []) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$packages]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function hasPackage(PackageInterface $package) { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'hasPackage', [$package]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/FilesystemRepository.php b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/FilesystemRepository.php index 00e42ac6..99c225cf 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/FilesystemRepository.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/FilesystemRepository.php @@ -5,10 +5,19 @@ namespace Composer\Repository; +use Composer\Json\JsonFile; +use Composer\Package\RootPackageInterface; use Composer\Pcre\Preg; +use Composer\Util\Filesystem; class FilesystemRepository extends WritableArrayRepository { + public function __construct(JsonFile $repositoryFile, bool $dumpVersions = false, ?RootPackageInterface $rootPackage = null, ?Filesystem $filesystem = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$repositoryFile, $dumpVersions, $rootPackage, $filesystem]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public static function safelyLoadInstalledVersions(string $path): bool { $installedVersionsData = @file_get_contents($path); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php index 5635c0dc..9f3567d3 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Repository/RepositoryManager.php @@ -5,7 +5,12 @@ namespace Composer\Repository; +use Composer\IO\IOInterface; +use Composer\Config; +use Composer\EventDispatcher\EventDispatcher; use Composer\Package\PackageInterface; +use Composer\Util\HttpDownloader; +use Composer\Util\ProcessExecutor; class RepositoryManager implements \ShirabeRustStub { @@ -14,16 +19,12 @@ class RepositoryManager implements \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle = 0, int $epoch = 0) + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void { - if (func_num_args() < 2) { - // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`) - // is an open question of the plugin design; only proxy instantiation passes a - // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError. - throw new \RuntimeException( - 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet' - ); - } $this->__rhandle = $rhandle; $this->__epoch = $epoch; } @@ -52,6 +53,12 @@ class RepositoryManager implements \ShirabeRustStub \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); } + public function __construct(IOInterface $io, Config $config, HttpDownloader $httpDownloader, ?EventDispatcher $eventDispatcher = null, ?ProcessExecutor $process = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$io, $config, $httpDownloader, $eventDispatcher, $process]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function findPackage(string $name, $constraint): ?PackageInterface { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'findPackage', [$name, $constraint]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php index a93d0dfc..065e79e2 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php @@ -11,6 +11,12 @@ use Composer\EventDispatcher\Event as BaseEvent; class Event extends BaseEvent { + public function __construct(string $name, Composer $composer, IOInterface $io, bool $devMode = false, array $args = [], array $flags = []) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$name, $composer, $io, $devMode, $args, $flags]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + public function getComposer(): Composer { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getComposer', []); diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php index 12297c83..9a36abdd 100644 --- a/crates/shirabe-php-rpc/php/worker.php +++ b/crates/shirabe-php-rpc/php/worker.php @@ -25,6 +25,9 @@ interface ShirabeRustStub * @return ?array{__rhandle: int, __class: string, __epoch: int} */ public function __shirabeRustHandleDescriptor(): ?array; + + /** Binds this stub to an existing Rust entity, in place of running its constructor. */ + public function __shirabeBind(int $rhandle, int $epoch): void; } /** Interns proxy stubs so the same Rust handle always yields the same stub instance. */ @@ -46,7 +49,10 @@ final class ShirabeRustObjectRegistry "no proxy stub class is available for {$class}" ); } - $stub = new $class($rhandle, $epoch); + // The stub's constructor belongs to plugin code building a *new* entity; an entity that + // already exists is bound directly, so proxying never runs it. + $stub = (new ReflectionClass($class))->newInstanceWithoutConstructor(); + $stub->__shirabeBind($rhandle, $epoch); self::$internTable[$rhandle] = WeakReference::create($stub); return $stub; } @@ -626,6 +632,53 @@ ShirabeRpcRuntime::$dispatch = [ } return true; }, + // An already-fulfilled promise for a Rust-side call whose PHP signature declares + // PromiseInterface. The Rust future ran to completion before this is called, so there is + // nothing left to defer; see .ken/plugin-arch/design.md §10.1.6. + '__shirabe_resolved_promise' => static function ($args) { + if (!function_exists('React\\Promise\\resolve')) { + throw new RuntimeException( + 'react/promise is not loaded in the plugin process, so a PromiseInterface cannot be built' + ); + } + return \React\Promise\resolve($args[0]); + }, + // Drains a promise a plugin returned to the Rust side. React settles promises + // synchronously, so an already-settled one runs these handlers during then(); one that is + // still pending is an explicit error rather than a silently dropped continuation. + '__shirabe_settle_promise' => static function ($args) { + $promise = $args[0]; + if (!$promise instanceof \React\Promise\PromiseInterface) { + throw new RuntimeException('__shirabe_settle_promise expects a promise handle'); + } + $settled = false; + $value = null; + $rejected = false; + $reason = null; + $promise->then( + static function ($result) use (&$settled, &$value) { + $settled = true; + $value = $result; + }, + static function ($error) use (&$settled, &$rejected, &$reason) { + $settled = true; + $rejected = true; + $reason = $error; + } + ); + if (!$settled) { + throw new RuntimeException( + 'the promise returned to Shirabe is still pending; deferred resolution across the' + . ' RPC boundary is not implemented yet' + ); + } + if ($rejected) { + throw $reason instanceof Throwable + ? $reason + : new RuntimeException('the promise returned to Shirabe was rejected with ' . gettype($reason)); + } + return $value; + }, // For testing only: reads a public property of a P-table entity (PHPUnit asserts like // `$plugins[0]->version` have no method to call). '__shirabe_get_property' => static function ($args) { diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index ef11d542..4f9ba6b5 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -578,6 +578,14 @@ const STUB_FILES: &[(&str, &str)] = &[ "Composer/Composer.php", include_str!("../php/stubs/Composer/Composer.php"), ), + ( + "Composer/Config.php", + include_str!("../php/stubs/Composer/Config.php"), + ), + ( + "Composer/Downloader/DownloadManager.php", + include_str!("../php/stubs/Composer/Downloader/DownloadManager.php"), + ), ( "Composer/IO/BaseIO.php", include_str!("../php/stubs/Composer/IO/BaseIO.php"), diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index fad6566e..4aff50c9 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -1686,6 +1686,9 @@ impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { }, ); } + if method_name == "__shirabeConstruct" { + return crate::plugin::php_plugin_proxy::construct_entity(&args); + } return Err(runtime_throw(format!( "unknown runtime service method `{method_name}`" ))); diff --git a/crates/shirabe/src/package/handle.rs b/crates/shirabe/src/package/handle.rs index 4d78734f..aed8be0f 100644 --- a/crates/shirabe/src/package/handle.rs +++ b/crates/shirabe/src/package/handle.rs @@ -64,8 +64,9 @@ impl AnyPackage { } } - /// For testing only: reach the base `Package` of a real package variant. - /// Crate-private; the public `__set_*` test hatches are built on top of it. + /// The base `Package` of a real package variant, for the members `PackageInterface` does not + /// carry (`Package`'s own setters, which the subclasses inherit). The public `__set_*` test + /// hatches are built on top of it too. pub(crate) fn as_package_mut(&mut self) -> Option<&mut Package> { match self { Self::Package(p) => Some(p), diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 701e83cc..2b3622f2 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -37,6 +37,10 @@ use shirabe_php_shim::PhpMixed; #[derive(Debug, Clone)] enum RustEntity { Composer(ComposerHandle), + Config(std::rc::Rc>), + DownloadManager( + std::rc::Rc>, + ), Io(std::rc::Rc>), InstallationManager(std::rc::Rc>), RepositoryManager(std::rc::Rc>), @@ -54,6 +58,8 @@ fn entity_ptr_id(entity: &RustEntity) -> usize { RustEntity::Composer(composer) => { std::rc::Rc::as_ptr(composer.as_rc()) as *const () as usize } + RustEntity::Config(config) => std::rc::Rc::as_ptr(config) as *const () as usize, + RustEntity::DownloadManager(dm) => std::rc::Rc::as_ptr(dm) as *const () as usize, RustEntity::Io(io) => std::rc::Rc::as_ptr(io) as *const () as usize, RustEntity::InstallationManager(im) => std::rc::Rc::as_ptr(im) as *const () as usize, RustEntity::RepositoryManager(rm) => std::rc::Rc::as_ptr(rm) as *const () as usize, @@ -278,6 +284,9 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { Err(e) => Err(runtime_throw(format!("{e:#}"))), }; } + if method_name == "__shirabeConstruct" { + return construct_entity(&args); + } return Err(runtime_throw(format!( "unknown runtime service method `{method_name}`" ))); @@ -303,6 +312,10 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { Some(RustEntity::Composer(composer)) => { dispatch_composer_method(&composer, method_name) } + Some(RustEntity::Config(config)) => dispatch_config_method(&config, method_name, &args), + Some(RustEntity::DownloadManager(dm)) => { + dispatch_download_manager_method(&dm, method_name, &args) + } Some(RustEntity::InstallationManager(im)) => { dispatch_installation_manager_method(&im, method_name, &args) } @@ -310,7 +323,7 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { dispatch_repository_manager_method(&rm, method_name) } Some(RustEntity::Repository(repository)) => { - dispatch_repository_method(&repository, method_name) + dispatch_repository_method(&repository, method_name, &args) } Some(RustEntity::Package(package)) => { dispatch_package_method(&package, method_name, &args) @@ -323,6 +336,62 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { } } +/// Serves the constructor every proxy stub carries: plugin code writing `new SomeProxiedClass` +/// gets a fresh Rust-side entity, since the proxied FQCN has no implementation of its own in the +/// child. Classes whose entity cannot be built here are an explicit error, so a plugin never +/// ends up holding a second, unconnected instance of a Composer service. +pub(crate) fn construct_entity(args: &[PluginValue]) -> Result { + let (class, ctor_args) = match (args.first(), args.get(1)) { + // TODO(phase-e): lossy UTF-8; class names are bytes in PHP. + (Some(PluginValue::String(class)), Some(PluginValue::List(ctor_args))) => { + (String::from_utf8_lossy(class).into_owned(), ctor_args) + } + (Some(PluginValue::String(class)), None | Some(PluginValue::Array(_))) => { + // An empty PHP array crosses as a list; anything else keyed is a protocol error. + (String::from_utf8_lossy(class).into_owned(), &Vec::new()) + } + other => { + return Err(runtime_throw(format!( + "__shirabeConstruct expects a class name and an argument list, got {other:?}" + ))); + } + }; + let string_arg = |position: usize| -> Result { + match ctor_args.get(position) { + Some(PluginValue::String(bytes)) => Ok(String::from_utf8_lossy(bytes).into_owned()), + other => Err(runtime_throw(format!( + "{class} expects a string constructor argument at position {position}, got {other:?}" + ))), + } + }; + let entity = match class.as_str() { + "Composer\\Package\\Package" => AnyPackage::Package(crate::package::Package::new( + string_arg(0)?, + string_arg(1)?, + string_arg(2)?, + )), + "Composer\\Package\\CompletePackage" => AnyPackage::CompletePackage( + crate::package::CompletePackage::new(string_arg(0)?, string_arg(1)?, string_arg(2)?), + ), + // TODO(plugin): the remaining proxied classes get a construction story on demand, + // driven by explicit errors from real plugins. Each one has to decide what a + // plugin-built instance means for the Rust-side graph, which is why none of them is + // answered generically here. + other => { + return Err(runtime_throw(format!( + "Shirabe does not support constructing {other} inside the plugin process yet" + ))); + } + }; + let rhandle = register_entity(RustEntity::Package(std::rc::Rc::new( + std::cell::RefCell::new(entity), + ))); + Ok(PluginValue::List(vec![ + PluginValue::Int(rhandle as i64), + PluginValue::Int(0), + ])) +} + /// Serves the `__clone` forwarder every proxy stub carries. Only entities whose Rust type /// models the PHP clone answer; the rest are an explicit error, so a plugin cloning a live /// service never silently ends up with two stubs over one entity. @@ -337,6 +406,8 @@ fn clone_entity(entity: &RustEntity) -> Result { ))) } RustEntity::Composer(_) + | RustEntity::Config(_) + | RustEntity::DownloadManager(_) | RustEntity::Io(_) | RustEntity::InstallationManager(_) | RustEntity::RepositoryManager(_) @@ -386,7 +457,20 @@ fn dispatch_composer_method( "Composer\\EventDispatcher\\EventDispatcher", )) } - // TODO(plugin): the remaining Composer object graph (getConfig, getLocker, ...) + "getConfig" => { + let config = composer.borrow().get_config(); + let rhandle = register_entity(RustEntity::Config(config)); + Ok(rust_handle_value(rhandle, "Composer\\Config")) + } + "getDownloadManager" => { + let dm = composer.borrow().get_download_manager(); + let rhandle = register_entity(RustEntity::DownloadManager(dm)); + Ok(rust_handle_value( + rhandle, + "Composer\\Downloader\\DownloadManager", + )) + } + // TODO(plugin): the remaining Composer object graph (getLocker, getPluginManager, ...) // becomes reachable over RPC on demand, driven by explicit errors from real plugins. other => Err(runtime_throw(format!( "the Composer method `{other}` is not available over RPC yet" @@ -394,6 +478,217 @@ fn dispatch_composer_method( } } +fn dispatch_config_method( + config: &std::rc::Rc>, + method_name: &str, + args: &[PluginValue], +) -> Result { + let key = |position: usize| -> Result { + match args.get(position) { + // TODO(phase-e): lossy UTF-8; config keys are bytes in PHP. + Some(PluginValue::String(bytes)) => Ok(String::from_utf8_lossy(bytes).into_owned()), + other => Err(runtime_throw(format!( + "{method_name} expects a string key, got {other:?}" + ))), + } + }; + let flags = |position: usize| -> Result { + match args.get(position) { + None | Some(PluginValue::Null) => Ok(0), + Some(PluginValue::Int(flags)) => Ok(*flags), + other => Err(runtime_throw(format!( + "{method_name} expects int flags, got {other:?}" + ))), + } + }; + match method_name { + "get" => { + let value = config + .borrow() + .get_with_flags(&key(0)?, flags(1)?) + .map_err(|error| runtime_throw(format!("get failed over RPC: {error}")))?; + Ok(PluginValue::from_php_mixed(&value)) + } + "all" => { + let all = config + .borrow_mut() + .all(flags(0)?) + .map_err(|error| runtime_throw(format!("all failed over RPC: {error}")))?; + Ok(PluginValue::from_php_mixed(&PhpMixed::Array(all))) + } + "raw" => Ok(PluginValue::from_php_mixed(&PhpMixed::Array( + config.borrow().raw(), + ))), + "has" => Ok(PluginValue::Bool(config.borrow().has(&key(0)?))), + "getRepositories" => Ok(PluginValue::from_php_mixed(&PhpMixed::Array( + config.borrow().get_repositories(), + ))), + "getSourceOfValue" => Ok(PluginValue::string( + config.borrow_mut().get_source_of_value(&key(0)?), + )), + "merge" => { + let values = match args.first().map(PluginValue::to_php_mixed).transpose() { + Ok(Some(PhpMixed::Array(values))) => values, + Ok(Some(PhpMixed::List(items))) if items.is_empty() => IndexMap::new(), + Ok(other) => { + return Err(runtime_throw(format!( + "merge expects a config array, got {other:?}" + ))); + } + Err(error) => { + return Err(runtime_throw(format!( + "merge could not decode its argument: {error:#}" + ))); + } + }; + let source = match args.get(1) { + None => crate::config::Config::SOURCE_UNKNOWN.to_string(), + Some(PluginValue::String(bytes)) => String::from_utf8_lossy(bytes).into_owned(), + other => { + return Err(runtime_throw(format!( + "merge expects a string source, got {other:?}" + ))); + } + }; + config.borrow_mut().merge(&values, &source); + Ok(PluginValue::Null) + } + // TODO(plugin): the config-source surface (getConfigSource / setAuthConfigSource / ...) + // needs proxy stubs for ConfigSourceInterface implementations, and prohibitUrlByConfig + // needs the IO argument decoded back into the Rust-side instance; both are widened on + // demand, driven by explicit errors from real plugins. + other => Err(runtime_throw(format!( + "the Config method `{other}` is not available over RPC yet" + ))), + } +} + +/// The download manager's contract is asynchronous on both sides: PHP declares a +/// `PromiseInterface` return, Rust an `async fn`. The Rust future is driven to completion here +/// and its value handed back as an already-settled React promise, which is the synchronous +/// fallback of the promise design (`.ken/plugin-arch/design.md` §10.1.6) rather than the +/// deferred resolution a concurrent engine would allow. +fn dispatch_download_manager_method( + dm: &std::rc::Rc>, + method_name: &str, + args: &[PluginValue], +) -> Result { + let string_arg = |position: usize| -> Result { + match args.get(position) { + // TODO(phase-e): lossy UTF-8; paths and types are bytes in PHP. + Some(PluginValue::String(bytes)) => Ok(String::from_utf8_lossy(bytes).into_owned()), + other => Err(runtime_throw(format!( + "{method_name} expects a string argument at position {position}, got {other:?}" + ))), + } + }; + match method_name { + "setPreferSource" | "setPreferDist" => { + let preferred = match args.first() { + Some(PluginValue::Bool(preferred)) => *preferred, + other => { + return Err(runtime_throw(format!( + "{method_name} expects a bool, got {other:?}" + ))); + } + }; + if method_name == "setPreferSource" { + dm.borrow_mut().set_prefer_source(preferred); + } else { + dm.borrow_mut().set_prefer_dist(preferred); + } + Ok(PluginValue::Null) + } + "download" | "prepare" | "install" | "update" | "remove" | "cleanup" => { + let resolved = crate::util::sync_executor::block_on(async { + let dm = dm.borrow(); + match method_name { + "download" => { + dm.download( + package_from_arg(method_name, args.first())?, + &string_arg(1)?, + optional_package_from_arg(method_name, args.get(2))?, + ) + .await + } + "prepare" => { + dm.prepare( + &string_arg(0)?, + package_from_arg(method_name, args.get(1))?, + &string_arg(2)?, + optional_package_from_arg(method_name, args.get(3))?, + ) + .await + } + "install" => { + dm.install( + package_from_arg(method_name, args.first())?, + &string_arg(1)?, + ) + .await + } + "update" => { + dm.update( + package_from_arg(method_name, args.first())?, + package_from_arg(method_name, args.get(1))?, + &string_arg(2)?, + ) + .await + } + "remove" => { + dm.remove( + package_from_arg(method_name, args.first())?, + &string_arg(1)?, + ) + .await + } + _ => { + dm.cleanup( + &string_arg(0)?, + package_from_arg(method_name, args.get(1))?, + &string_arg(2)?, + optional_package_from_arg(method_name, args.get(3))?, + ) + .await + } + } + .map_err(|error| { + // TODO(plugin): the original exception class is collapsed to + // RuntimeException on this side of the boundary. + runtime_throw(format!("{method_name} failed over RPC: {error:#}")) + }) + })?; + let value = match resolved { + Some(value) => PluginValue::from_php_mixed(&value), + None => PluginValue::Null, + }; + resolved_promise(value) + } + // TODO(plugin): the downloader-facing surface (getDownloader / setDownloader / + // getDownloaderForPackage / getDownloaderType) needs proxy stubs for + // DownloaderInterface implementations before it can cross. + other => Err(runtime_throw(format!( + "the DownloadManager method `{other}` is not available over RPC yet" + ))), + } +} + +/// A `\React\Promise\PromiseInterface` already fulfilled with `value`, built in the worker so +/// PHP callers get the object type their signatures declare. +fn resolved_promise(value: PluginValue) -> Result { + match call_function_with_dispatcher( + "__shirabe_resolved_promise", + vec![value], + Some(&mut PluginRpcDispatcher::default()), + ) { + Ok(Ok(promise)) => Ok(promise), + Ok(Err(throw)) => Err(throw), + Err(error) => Err(runtime_throw(format!( + "creating a resolved promise in the plugin process failed: {error:#}" + ))), + } +} + fn dispatch_event_dispatcher_method( dispatcher: &std::rc::Rc< std::cell::RefCell, @@ -451,8 +746,40 @@ fn dispatch_repository_manager_method( fn dispatch_repository_method( repository: &RepositoryInterfaceHandle, method_name: &str, + args: &[PluginValue], ) -> Result { match method_name { + "hasPackage" => { + let package = package_from_arg(method_name, args.first())?; + let has = repository.has_package(package).map_err(|error| { + // TODO(plugin): the original exception class is collapsed to RuntimeException + // on this side of the boundary. + runtime_throw(format!("hasPackage failed over RPC: {error}")) + })?; + Ok(PluginValue::Bool(has)) + } + "addPackage" | "removePackage" => { + let package = package_from_arg(method_name, args.first())?; + let mut borrowed = repository.borrow_mut(); + let writable = borrowed + .as_writable_repository_interface_mut() + .ok_or_else(|| { + runtime_throw(format!( + "the repository behind this handle is not writable, so `{method_name}` cannot be called on it" + )) + })?; + let outcome = if method_name == "addPackage" { + writable.add_package(package) + } else { + writable.remove_package(package) + }; + outcome.map_err(|error| { + // TODO(plugin): the original exception class is collapsed to RuntimeException + // on this side of the boundary. + runtime_throw(format!("{method_name} failed over RPC: {error}")) + })?; + Ok(PluginValue::Null) + } "getPackages" => { let packages = repository.borrow_mut().get_packages().map_err(|error| { // TODO(plugin): the original exception class is collapsed to RuntimeException @@ -555,6 +882,226 @@ fn decode_mirrors( Ok(Some(mirrors)) } +/// An `array` of plain values as PHP shapes it, via the `PhpMixed` image of `T`. +fn typed_map(map: IndexMap, to_mixed: impl Fn(T) -> PhpMixed) -> PluginValue { + string_keyed_map( + map.into_iter() + .map(|(key, value)| (key, to_mixed(value))) + .collect(), + ) +} + +/// A `list>` as PHP shapes it. +fn typed_map_list( + rows: Vec>, + to_mixed: impl Fn(T) -> PhpMixed + Copy, +) -> PluginValue { + PluginValue::List( + rows.into_iter() + .map(|row| typed_map(row, to_mixed)) + .collect(), + ) +} + +/// The `PhpMixed` image of an argument, as the wire codec decoded it. +fn mixed_arg(method: &str, value: Option<&PluginValue>) -> Result { + match value { + None => Ok(PhpMixed::Null), + Some(value) => value.to_php_mixed().map_err(|error| { + runtime_throw(format!("{method} could not decode its argument: {error:#}")) + }), + } +} + +/// A PHP array argument as a map. An empty PHP array is indistinguishable from an empty list on +/// the wire, so it decodes here as an empty map. +fn map_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result, PhpThrow> { + match mixed_arg(method, value)? { + PhpMixed::Array(map) => Ok(map), + PhpMixed::List(items) if items.is_empty() => Ok(IndexMap::new()), + PhpMixed::List(items) => Ok(items + .into_iter() + .enumerate() + .map(|(index, item)| (index.to_string(), item)) + .collect()), + other => Err(runtime_throw(format!( + "{method} expects an array, got {other:?}" + ))), + } +} + +/// A PHP array argument as a list, accepting the keyed shape PHP allows anywhere a list is +/// documented. +fn list_arg(method: &str, value: Option<&PluginValue>) -> Result, PhpThrow> { + match mixed_arg(method, value)? { + PhpMixed::List(items) => Ok(items), + PhpMixed::Array(map) => Ok(map.into_values().collect()), + other => Err(runtime_throw(format!( + "{method} expects an array, got {other:?}" + ))), + } +} + +fn as_string(method: &str, value: PhpMixed) -> Result { + match value { + PhpMixed::String(value) => Ok(value), + other => Err(runtime_throw(format!( + "{method} expects strings, got {other:?}" + ))), + } +} + +fn as_int(method: &str, value: PhpMixed) -> Result { + match value { + PhpMixed::Int(value) => Ok(value), + other => Err(runtime_throw(format!( + "{method} expects ints, got {other:?}" + ))), + } +} + +fn string_map_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result, PhpThrow> { + map_arg(method, value)? + .into_iter() + .map(|(key, value)| Ok((key, as_string(method, value)?))) + .collect() +} + +fn int_map_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result, PhpThrow> { + map_arg(method, value)? + .into_iter() + .map(|(key, value)| Ok((key, as_int(method, value)?))) + .collect() +} + +fn string_list_arg(method: &str, value: Option<&PluginValue>) -> Result, PhpThrow> { + list_arg(method, value)? + .into_iter() + .map(|item| as_string(method, item)) + .collect() +} + +/// A `list>` argument (`authors`, `aliases`). +fn string_map_list_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result>, PhpThrow> { + list_arg(method, value)? + .into_iter() + .map(|row| match row { + PhpMixed::Array(row) => row + .into_iter() + .map(|(key, value)| Ok((key, as_string(method, value)?))) + .collect(), + PhpMixed::List(items) if items.is_empty() => Ok(IndexMap::new()), + other => Err(runtime_throw(format!( + "{method} expects arrays of strings, got {other:?}" + ))), + }) + .collect() +} + +/// A `list>` argument (`funding`). +fn mixed_map_list_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result>, PhpThrow> { + list_arg(method, value)? + .into_iter() + .map(|row| match row { + PhpMixed::Array(row) => Ok(row), + PhpMixed::List(items) if items.is_empty() => Ok(IndexMap::new()), + other => Err(runtime_throw(format!( + "{method} expects arrays, got {other:?}" + ))), + }) + .collect() +} + +/// An `array>` argument (`scripts`). +fn string_list_map_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result>, PhpThrow> { + map_arg(method, value)? + .into_iter() + .map(|(key, value)| { + let items = match value { + PhpMixed::List(items) => items, + PhpMixed::Array(map) => map.into_values().collect(), + other => { + return Err(runtime_throw(format!( + "{method} expects arrays of strings, got {other:?}" + ))); + } + }; + Ok(( + key, + items + .into_iter() + .map(|item| as_string(method, item)) + .collect::>()?, + )) + }) + .collect() +} + +fn bool_arg(method: &str, value: Option<&PluginValue>) -> Result { + match value { + Some(PluginValue::Bool(value)) => Ok(*value), + other => Err(runtime_throw(format!( + "{method} expects a bool, got {other:?}" + ))), + } +} + +fn required_string_arg(method: &str, value: Option<&PluginValue>) -> Result { + decode_optional_string(method, value)? + .ok_or_else(|| runtime_throw(format!("{method} expects a string"))) +} + +/// Resolves a package argument back to the Rust-side entity its proxy stub stands for. +fn package_from_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result { + match value { + Some(PluginValue::RustHandle(handle)) => { + match R_TABLE.with(|table| table.borrow().get(&handle.rhandle).cloned()) { + Some(RustEntity::Package(package)) => { + Ok(PackageInterfaceHandle::from_rc_unchecked(package)) + } + _ => Err(runtime_throw(format!( + "{method} expects a package handle, got Rust handle {}", + handle.rhandle + ))), + } + } + other => Err(runtime_throw(format!( + "{method} expects a package argument, got {other:?}" + ))), + } +} + +fn optional_package_from_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result, PhpThrow> { + match value { + None | Some(PluginValue::Null) => Ok(None), + other => Ok(Some(package_from_arg(method, other)?)), + } +} + fn decode_optional_string( method: &str, value: Option<&PluginValue>, @@ -568,6 +1115,75 @@ fn decode_optional_string( } } +/// The read half of the `CompletePackage` / `RootPackage` surface, which `PackageInterface` +/// does not carry. `None` means the method belongs to the base surface below. +fn dispatch_complete_package_getter( + package: &AnyPackage, + method_name: &str, +) -> Result, PhpThrow> { + let unavailable = || { + runtime_throw(format!( + "`{method_name}` is not available on this package over RPC" + )) + }; + let value = match method_name { + "getScripts" + | "getRepositories" + | "getLicense" + | "getKeywords" + | "getDescription" + | "getHomepage" + | "getAuthors" + | "getSupport" + | "getFunding" + | "isAbandoned" + | "getReplacementPackage" + | "getArchiveName" + | "getArchiveExcludes" => { + let package = package + .as_complete_package_interface() + .ok_or_else(unavailable)?; + match method_name { + "getScripts" => typed_map(package.get_scripts(), |commands| { + PhpMixed::List(commands.into_iter().map(PhpMixed::String).collect()) + }), + "getRepositories" => string_keyed_map(package.get_repositories()), + "getLicense" => string_list(package.get_license()), + "getKeywords" => string_list(package.get_keywords()), + "getDescription" => optional_string(package.get_description()), + "getHomepage" => optional_string(package.get_homepage()), + "getAuthors" => typed_map_list(package.get_authors(), PhpMixed::String), + "getSupport" => typed_map(package.get_support(), PhpMixed::String), + "getFunding" => typed_map_list(package.get_funding(), |value| value), + "isAbandoned" => PluginValue::Bool(package.is_abandoned()), + "getReplacementPackage" => optional_string(package.get_replacement_package()), + "getArchiveName" => optional_string(package.get_archive_name()), + _ => string_list(package.get_archive_excludes()), + } + } + "getAliases" + | "getMinimumStability" + | "getStabilityFlags" + | "getReferences" + | "getPreferStable" + | "getConfig" => { + let package = package + .as_root_package_interface() + .ok_or_else(unavailable)?; + match method_name { + "getAliases" => typed_map_list(package.get_aliases(), PhpMixed::String), + "getMinimumStability" => PluginValue::string(package.get_minimum_stability()), + "getStabilityFlags" => typed_map(package.get_stability_flags(), PhpMixed::Int), + "getReferences" => typed_map(package.get_references(), PhpMixed::String), + "getPreferStable" => PluginValue::Bool(package.get_prefer_stable()), + _ => string_keyed_map(package.get_config()), + } + } + _ => return Ok(None), + }; + Ok(Some(value)) +} + fn dispatch_package_method( package: &std::rc::Rc>, method_name: &str, @@ -685,9 +1301,214 @@ fn dispatch_package_method( .set_transport_options(options); return Ok(PluginValue::Null); } + // `Package`'s own setters. The concrete subclasses inherit them (their PHP overrides in + // `RootPackage` delegate to the same base state), so the base package answers for every + // real variant. + "setType" + | "setTargetDir" + | "setExtra" + | "setBinaries" + | "setSourceType" + | "setDistSha1Checksum" + | "setSuggests" + | "setAutoload" + | "setDevAutoload" + | "setIncludePaths" + | "setPhpExt" + | "setNotificationUrl" + | "setIsDefaultBranch" + | "replaceVersion" => { + let mut borrowed = package.borrow_mut(); + let package = borrowed.as_package_mut().ok_or_else(|| { + runtime_throw(format!( + "`{method_name}` is not available on an alias package over RPC" + )) + })?; + match method_name { + "setType" => package.set_type(required_string_arg(method_name, args.first())?), + "setTargetDir" => { + package.set_target_dir(decode_optional_string(method_name, args.first())?) + } + "setExtra" => package.set_extra(map_arg(method_name, args.first())?), + "setBinaries" => package.set_binaries(string_list_arg(method_name, args.first())?), + "setSourceType" => { + package.set_source_type(decode_optional_string(method_name, args.first())?) + } + "setDistSha1Checksum" => package + .set_dist_sha1_checksum(decode_optional_string(method_name, args.first())?), + "setSuggests" => package.set_suggests(string_map_arg(method_name, args.first())?), + "setAutoload" => package.set_autoload(map_arg(method_name, args.first())?), + "setDevAutoload" => package.set_dev_autoload(map_arg(method_name, args.first())?), + "setIncludePaths" => { + package.set_include_paths(string_list_arg(method_name, args.first())?) + } + "setPhpExt" => package.set_php_ext(match args.first() { + None | Some(PluginValue::Null) => None, + value => Some(map_arg(method_name, value)?), + }), + "setNotificationUrl" => { + package.set_notification_url(required_string_arg(method_name, args.first())?) + } + "setIsDefaultBranch" => { + package.set_is_default_branch(bool_arg(method_name, args.first())?) + } + _ => package.replace_version( + required_string_arg(method_name, args.first())?, + required_string_arg(method_name, args.get(1))?, + ), + } + return Ok(PluginValue::Null); + } + // TODO(plugin): the link setters take `array`, whose wire image is missing + // for the same reason the link getters below have none. + "setRequires" | "setConflicts" | "setProvides" | "setReplaces" | "setDevRequires" => { + if !list_arg(method_name, args.first())?.is_empty() + || !map_arg(method_name, args.first())?.is_empty() + { + return Err(runtime_throw(format!( + "the package method `{method_name}` takes Link values, whose encoding over RPC is not implemented yet" + ))); + } + let mut borrowed = package.borrow_mut(); + let package = borrowed.as_package_mut().ok_or_else(|| { + runtime_throw(format!( + "`{method_name}` is not available on an alias package over RPC" + )) + })?; + match method_name { + "setRequires" => package.set_requires(IndexMap::new()), + "setConflicts" => package.set_conflicts(IndexMap::new()), + "setProvides" => package.set_provides(IndexMap::new()), + "setReplaces" => package.set_replaces(IndexMap::new()), + _ => package.set_dev_requires(IndexMap::new()), + } + return Ok(PluginValue::Null); + } + // TODO(plugin): a \DateTimeInterface argument has to be decoded from a real PHP object in + // the child, which needs the value-object encoding `getReleaseDate` is missing too. + "setReleaseDate" => { + return match args.first() { + None | Some(PluginValue::Null) => { + let mut borrowed = package.borrow_mut(); + let package = borrowed.as_package_mut().ok_or_else(|| { + runtime_throw( + "`setReleaseDate` is not available on an alias package over RPC" + .to_string(), + ) + })?; + package.set_release_date(None); + Ok(PluginValue::Null) + } + _ => Err(runtime_throw( + "decoding a release date over RPC is not implemented yet".to_string(), + )), + }; + } + "setScripts" | "setRepositories" | "setLicense" | "setKeywords" | "setDescription" + | "setHomepage" | "setAuthors" | "setSupport" | "setFunding" | "setAbandoned" + | "setArchiveName" | "setArchiveExcludes" => { + let mut borrowed = package.borrow_mut(); + let package = borrowed + .as_complete_package_interface_mut() + .ok_or_else(|| { + runtime_throw(format!( + "`{method_name}` is not available on this package over RPC" + )) + })?; + match method_name { + "setScripts" => { + package.set_scripts(string_list_map_arg(method_name, args.first())?) + } + "setRepositories" => package.set_repositories(map_arg(method_name, args.first())?), + "setLicense" => package.set_license(string_list_arg(method_name, args.first())?), + "setKeywords" => package.set_keywords(string_list_arg(method_name, args.first())?), + "setDescription" => { + package.set_description(required_string_arg(method_name, args.first())?) + } + "setHomepage" => { + package.set_homepage(required_string_arg(method_name, args.first())?) + } + "setAuthors" => { + package.set_authors(string_map_list_arg(method_name, args.first())?) + } + "setSupport" => package.set_support(string_map_arg(method_name, args.first())?), + "setFunding" => package.set_funding(mixed_map_list_arg(method_name, args.first())?), + "setAbandoned" => package.set_abandoned(mixed_arg(method_name, args.first())?), + "setArchiveName" => { + package.set_archive_name(required_string_arg(method_name, args.first())?) + } + _ => package.set_archive_excludes(string_list_arg(method_name, args.first())?), + } + return Ok(PluginValue::Null); + } + "setStabilityFlags" + | "setMinimumStability" + | "setPreferStable" + | "setConfig" + | "setReferences" + | "setAliases" => { + let mut borrowed = package.borrow_mut(); + let package = borrowed.as_root_package_interface_mut().ok_or_else(|| { + runtime_throw(format!( + "`{method_name}` is not available on this package over RPC" + )) + })?; + match method_name { + "setStabilityFlags" => { + package.set_stability_flags(int_map_arg(method_name, args.first())?) + } + "setMinimumStability" => { + package.set_minimum_stability(required_string_arg(method_name, args.first())?) + } + "setPreferStable" => { + package.set_prefer_stable(bool_arg(method_name, args.first())?) + } + "setConfig" => package.set_config(map_arg(method_name, args.first())?), + "setReferences" => { + package.set_references(string_map_arg(method_name, args.first())?) + } + _ => package.set_aliases(string_map_list_arg(method_name, args.first())?), + } + return Ok(PluginValue::Null); + } + // `BasePackage::$id` is the one public property of the package classes, so the stub's + // property forwarders only ever carry it. + "__get" | "__set" => { + let property = required_string_arg(method_name, args.first())?; + if property != "id" { + return Err(runtime_throw(format!( + "the package property `{property}` is not available over RPC" + ))); + } + return if method_name == "__get" { + Ok(PluginValue::Int( + package.borrow().as_package_interface().get_id(), + )) + } else { + let id = match args.get(1) { + Some(PluginValue::Int(id)) => *id, + other => { + return Err(runtime_throw(format!( + "the package property `id` takes an int, got {other:?}" + ))); + } + }; + package.borrow_mut().as_package_interface_mut().set_id(id); + Ok(PluginValue::Null) + }; + } + "equals" => { + let other = package_from_arg(method_name, args.first())?; + let this = PackageInterfaceHandle::from_rc_unchecked(package.clone()); + return Ok(PluginValue::Bool(this.equals(&other))); + } _ => {} } + if let Some(value) = dispatch_complete_package_getter(&package.borrow(), method_name)? { + return Ok(value); + } + let borrowed = package.borrow(); let package = borrowed.as_package_interface(); match method_name { @@ -788,6 +1609,16 @@ fn dispatch_package_method( "__toString" => Ok(PluginValue::string(package.get_unique_name())), "getPrettyString" => Ok(PluginValue::string(package.get_pretty_string())), "isDefaultBranch" => Ok(PluginValue::Bool(package.is_default_branch())), + // `BasePackage`'s concrete methods are not forwarded by `PackageInterface`, so both are + // computed from the interface here, as `VersionSelector` already does for the second. + "isPlatform" => Ok(PluginValue::Bool(package.get_repository().is_some_and( + |repository| repository.is::(), + ))), + "getStabilityPriority" => Ok(PluginValue::Int( + *crate::package::base_package::STABILITIES + .get(package.get_stability()) + .unwrap_or(&crate::package::base_package::STABILITY_STABLE), + )), "getTransportOptions" => Ok(string_keyed_map(package.get_transport_options())), "getReleaseDate" => match package.get_release_date() { None => Ok(PluginValue::Null), @@ -797,9 +1628,6 @@ fn dispatch_package_method( "encoding the release date over RPC is not implemented yet".to_string(), )), }, - // TODO(plugin): the concrete-class surface below PackageInterface (`Package`'s setters, - // `CompletePackage`'s metadata, `RootPackage`'s root-only state) is widened on demand, - // driven by explicit errors from real plugins. other => Err(runtime_throw(format!( "the package method `{other}` is not available over RPC yet" ))), @@ -813,27 +1641,7 @@ fn dispatch_installation_manager_method( ) -> Result { match method_name { "getInstallPath" => { - let package = match args.first() { - Some(PluginValue::RustHandle(handle)) => { - let entity = R_TABLE.with(|table| table.borrow().get(&handle.rhandle).cloned()); - match entity { - Some(RustEntity::Package(package)) => { - PackageInterfaceHandle::from_rc_unchecked(package) - } - _ => { - return Err(runtime_throw(format!( - "getInstallPath expects a package handle, got Rust handle {}", - handle.rhandle - ))); - } - } - } - other => { - return Err(runtime_throw(format!( - "getInstallPath expects a package argument, got {other:?}" - ))); - } - }; + let package = package_from_arg(method_name, args.first())?; Ok(match im.borrow().get_install_path(package) { Some(path) => PluginValue::string(path), None => PluginValue::Null, @@ -1326,19 +2134,32 @@ impl PhpInstallerProxy { Ok(repository_handle_value(&repo.as_repository_handle())?) } - /// The `?PromiseInterface` half of the installer contract. A plugin installer that returns - /// a real promise needs the promise machinery the RPC boundary does not carry yet, so it is - /// an explicit error rather than a silently dropped continuation. + /// The `?PromiseInterface` half of the installer contract. The Rust callers await the + /// installer's effects rather than chaining continuations, so a returned promise is drained + /// here: an already-settled one yields its value (or raises its rejection reason), while a + /// still-pending one is an explicit error — resolving it would need the concurrent execution + /// engine the boundary does not have (`.ken/plugin-arch/design.md` §10.1.6). fn promise_result(&self, method: &str, value: PluginValue) -> anyhow::Result> { - match value { + let handle = match value { + PluginValue::Null => return Ok(None), + PluginValue::PhpHandle(handle) => handle, + other => return Err(self.unsupported_shape(method, &other)), + }; + if !php_is_a(&handle, "React\\Promise\\PromiseInterface")? { + return Err(self.unsupported_shape(method, &PluginValue::PhpHandle(handle))); + } + let phandle = handle.phandle; + let settled = unwrap_php_result(call_function_with_dispatcher( + "__shirabe_settle_promise", + vec![PluginValue::PhpHandle(handle)], + Some(&mut PluginRpcDispatcher::default()), + )); + // The promise entity was interned in the worker's P table when it crossed; it has no + // owner on this side beyond this call, on the rejection path too. + let _ = release_php_handle(phandle); + match settled? { PluginValue::Null => Ok(None), - other => Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException { - message: format!( - "{}::{method}() returned a promise, which cannot cross the RPC boundary yet: {other:?}", - self.handle.class - ), - code: 0, - })), + other => Ok(Some(other.to_php_mixed()?)), } } -- cgit v1.3.1