diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-06 02:40:00 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-06 02:40:00 +0900 |
| commit | 90764c477f24e4d67d3e0cae943c33fd2b8ae220 (patch) | |
| tree | 8274fb1e266d6a14653c2a440cc6831ffda1c6bf | |
| parent | 0e89281dd7f057d3829508b8e6c5d85c3f111c45 (diff) | |
| download | php-shirabe-90764c477f24e4d67d3e0cae943c33fd2b8ae220.tar.gz php-shirabe-90764c477f24e4d67d3e0cae943c33fd2b8ae220.tar.zst php-shirabe-90764c477f24e4d67d3e0cae943c33fd2b8ae220.zip | |
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.
24 files changed, 1486 insertions, 144 deletions
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 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\Config: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer; + +use Composer\Advisory\Auditor; +use Composer\Config\ConfigSourceInterface; +use Composer\IO\IOInterface; +use Composer\Util\ProcessExecutor; + +class Config implements \ShirabeRustStub +{ + /** @var int */ + protected $__rhandle; + /** @var int */ + protected $__epoch; + + /** + * 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; + } + + 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 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\Downloader\DownloadManager: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\Downloader; + +use Composer\Package\PackageInterface; +use Composer\IO\IOInterface; +use Composer\Util\Filesystem; +use React\Promise\PromiseInterface; + +class DownloadManager implements \ShirabeRustStub +{ + /** @var int */ + protected $__rhandle; + /** @var int */ + protected $__epoch; + + /** + * 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; + } + + 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) + /** + * 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 +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) + /** + * 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 +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) + /** + * 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; } @@ -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) + /** + * 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 +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 @@ -579,6 +579,14 @@ const STUB_FILES: &[(&str, &str)] = &[ 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<std::cell::RefCell<crate::config::Config>>), + DownloadManager( + std::rc::Rc<std::cell::RefCell<dyn crate::downloader::DownloadManagerInterface>>, + ), Io(std::rc::Rc<std::cell::RefCell<dyn IOInterface>>), InstallationManager(std::rc::Rc<std::cell::RefCell<dyn InstallationManagerInterface>>), RepositoryManager(std::rc::Rc<std::cell::RefCell<dyn RepositoryManagerInterface>>), @@ -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<PluginValue, PhpThrow> { + 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<String, PhpThrow> { + 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<PluginValue, PhpThrow> { ))) } 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<std::cell::RefCell<crate::config::Config>>, + method_name: &str, + args: &[PluginValue], +) -> Result<PluginValue, PhpThrow> { + let key = |position: usize| -> Result<String, PhpThrow> { + 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<i64, PhpThrow> { + 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<std::cell::RefCell<dyn crate::downloader::DownloadManagerInterface>>, + method_name: &str, + args: &[PluginValue], +) -> Result<PluginValue, PhpThrow> { + let string_arg = |position: usize| -> Result<String, PhpThrow> { + 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<PluginValue, PhpThrow> { + 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<dyn crate::event_dispatcher::EventDispatcherInterface>, @@ -451,8 +746,40 @@ fn dispatch_repository_manager_method( fn dispatch_repository_method( repository: &RepositoryInterfaceHandle, method_name: &str, + args: &[PluginValue], ) -> Result<PluginValue, PhpThrow> { 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<string, T>` of plain values as PHP shapes it, via the `PhpMixed` image of `T`. +fn typed_map<T>(map: IndexMap<String, T>, to_mixed: impl Fn(T) -> PhpMixed) -> PluginValue { + string_keyed_map( + map.into_iter() + .map(|(key, value)| (key, to_mixed(value))) + .collect(), + ) +} + +/// A `list<array<string, T>>` as PHP shapes it. +fn typed_map_list<T>( + rows: Vec<IndexMap<String, T>>, + 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<PhpMixed, PhpThrow> { + 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<IndexMap<String, PhpMixed>, 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<Vec<PhpMixed>, 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<String, PhpThrow> { + 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<i64, PhpThrow> { + 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<IndexMap<String, String>, 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<IndexMap<String, i64>, 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<Vec<String>, PhpThrow> { + list_arg(method, value)? + .into_iter() + .map(|item| as_string(method, item)) + .collect() +} + +/// A `list<array<string, string>>` argument (`authors`, `aliases`). +fn string_map_list_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result<Vec<IndexMap<String, String>>, 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<array<string, mixed>>` argument (`funding`). +fn mixed_map_list_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result<Vec<IndexMap<String, PhpMixed>>, 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<string, list<string>>` argument (`scripts`). +fn string_list_map_arg( + method: &str, + value: Option<&PluginValue>, +) -> Result<IndexMap<String, Vec<String>>, 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::<Result<_, _>>()?, + )) + }) + .collect() +} + +fn bool_arg(method: &str, value: Option<&PluginValue>) -> Result<bool, PhpThrow> { + 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<String, PhpThrow> { + 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<PackageInterfaceHandle, PhpThrow> { + 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<Option<PackageInterfaceHandle>, 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<Option<PluginValue>, 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<std::cell::RefCell<AnyPackage>>, 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<string, Link>`, 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::<crate::repository::PlatformRepository>(), + ))), + "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<PluginValue, PhpThrow> { 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<Option<PhpMixed>> { - 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()?)), } } diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index e6797533..d0eed382 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -126,6 +126,13 @@ function; an unknown name is an explicit error. Notable internal helpers: (the unconditional `InstalledVersions::reload($versions)` plus the reflection-based `selfDir`/`installedIsLocalDir` restore) into the worker; skipped only when the class is not even autoloadable there, i.e. no Composer PHP runtime and therefore no observer code. +- `__shirabe_resolved_promise` — wraps a value in `\React\Promise\resolve()`, so a Rust method + whose PHP signature declares `PromiseInterface` (the `DownloadManager` surface) can answer + with the object type the caller expects. The Rust future has already run to completion by + then; deferred resolution across the boundary does not exist yet. +- `__shirabe_settle_promise` — the inverse: drains a promise a plugin returned to Rust. React + settles synchronously, so an already-settled promise yields its value here (a rejection is + re-thrown as the Throw reply); one that is still pending is an explicit error. - `__shirabe_get_property` — for testing only: reads a public property of a P-table entity. - `__shirabe_oracle_roundtrip` — codec oracle support for tests. - `__shirabe_console_application_boot` — builds the worker-side `Composer\Console\Application` @@ -138,10 +145,13 @@ function; an unknown name is an explicit error. Notable internal helpers: - `__shirabe_read_command_definition` — reads a command's input definition (plus help text and extra usages) as plain data, so the Rust side mirrors it for `help`/`list` rendering. -The runtime service endpoint (handle 0) answers `__shirabe_find_file` (autoload lookups) and +The runtime service endpoint (handle 0) answers `__shirabe_find_file` (autoload lookups), `__shirabe_run_rust_command` — the reverse half of the two-world command split: a `\Shirabe\RustCommandStub` forwards its stringified input here and the built-in command runs in -the Rust process, against the Rust-side application state. +the Rust process, against the Rust-side application state — and `__shirabeConstruct`, which +allocates the Rust entity behind a `new SomeProxiedClass(...)` written by plugin code and +answers with `[rhandle, epoch]`. Classes whose entity Rust cannot build are an explicit error +naming the class. ## Proxy stubs and runtime classes @@ -158,17 +168,19 @@ by `scripts/plugin-stub-generator/generate-stubs` and must not be edited by hand surface (`getIO()`/`getComposer()`/...) answers from the Rust handoff. - `Shirabe\RustCommandStub` — the reverse stub for built-in commands registered into that application. -- `Composer\EventDispatcher\Event` — dual-mode: revived from a Rust handle it proxies like a - generated stub, while a natively-constructed instance (real Composer code in the worker does - `new PreCommandRunEvent(...)`, whose parent constructor lands here) is a faithful in-process - port of the real base class and crosses the wire as a P-table entity - (`__shirabeRustHandleDescriptor()` returns null in native mode). +- `Composer\EventDispatcher\Event` — dual-mode: revived from a Rust handle (through + `__shirabeBind`) it proxies like a generated stub, while a natively-constructed instance (real + Composer code in the worker does `new PreCommandRunEvent(...)`, whose parent constructor lands + here) is a faithful in-process port of the real base class and crosses the wire as a P-table + entity (`__shirabeRustHandleDescriptor()` returns null in native mode). Both sets are written into the same autoload directory at worker spawn and resolved with highest priority, so these FQCNs can never be shadowed by the real implementation; `__shirabe_require` restores that priority after loading code that prepends its own autoloader. Stubs are interned per rhandle (`WeakReference`-based registry) so identity (`===`) holds, and -their destructors send `ReleaseRustHandle`. `clone` on a stub calls `__shirabeClone` on the +their destructors send `ReleaseRustHandle`. Reviving a stub for an existing entity bypasses its +constructor (`newInstanceWithoutConstructor` plus `__shirabeBind`), because the constructor +carries the real class's own signature and belongs to plugin code building a *new* entity. `clone` on a stub calls `__shirabeClone` on the entity and rebinds the copy to the handle that answers, so the two stubs never share (and never double-release) one entity; entities with no clone semantics answer with an explicit error. diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md index 33025df7..27835921 100644 --- a/docs/dev/plugin-stub-generation.md +++ b/docs/dev/plugin-stub-generation.md @@ -46,12 +46,18 @@ the generator's vendor directory or the classifier report is unavailable. ## What the generator emits * **Root stubs** (targets whose parent class is not itself a target) carry the - proxy boilerplate: `__rhandle`/`__epoch` properties, a constructor that - accepts `(rhandle, epoch)` from proxy instantiation and throws a diagnosable - `RuntimeException` when plugin code tries to `new` the class directly, a + proxy boilerplate: `__rhandle`/`__epoch` properties, the `__shirabeBind` + binder the registry calls when reviving a stub for an existing entity, a destructor releasing the Rust handle, and the wire descriptor helper. The real `extends`/`implements` hierarchy is preserved and `\ShirabeRustStub` is appended to the interface list. +* **Constructors** reproduce the real class's parameter list and forward to the + Rust side (`__shirabeConstruct` on handle 0), which allocates the entity and + answers with its handle; a class Rust cannot build answers with an explicit + error naming it. One is emitted for every root stub and for every subclass + that declares a public constructor of its own, so `new SomeProxiedClass(...)` + in plugin code never yields an unbound stub. Proxy revival does not run them + (see `__shirabeBind` above). * **Instance methods** forward via `\ShirabeRpcRuntime::callRust`. For a root stub the emitted surface is the interface closure (each interface before the ones it extends, methods in declaration order; a concrete redeclaration in @@ -89,8 +95,8 @@ Generation fails — instead of emitting something quietly wrong — on: * a target missing from the classifier report, classified other than `rust-proxy`/`contract`, or a report carrying violations, -* by-ref or variadic parameters, static interface methods, magic methods - other than `__toString`/`__clone`, +* by-ref or variadic parameters (in constructors too), static interface + methods, magic methods other than `__toString`/`__clone`, * an omitted override diverging from the inherited stub signature, * a subclass target listed before its base class, or extending a class that is neither a target nor provided by `php/runtime/`, diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php index ce8f612b..395155dd 100644 --- a/scripts/plugin-stub-generator/src/Generator.php +++ b/scripts/plugin-stub-generator/src/Generator.php @@ -23,16 +23,12 @@ final class Generator /** @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; } @@ -235,9 +231,13 @@ final class Generator $publicStatics = []; $nonPublicStatics = []; $ownInstanceMethods = []; + $constructor = null; foreach ($class->getMethods() as $method) { $name = $method->name->toString(); if ($name === '__construct') { + if ($method->isPublic()) { + $constructor = $method; + } continue; } if (str_starts_with($name, '__')) { @@ -332,6 +332,9 @@ final class Generator if ($isRoot) { $members[] = self::BOILERPLATE; } + if ($isRoot || $constructor !== null) { + $members[] = $this->renderConstructor($fqcn, $constructor, $file); + } if ($constants !== []) { $members[] = implode("\n", $constants); } @@ -355,6 +358,44 @@ final class Generator } /** + * The constructor plugin code reaches when it writes `new SomeProxiedClass(...)`. The real + * class's parameter list is reproduced and forwarded to the Rust side, which allocates the + * entity and answers with its handle; classes whose entity it cannot build answer with an + * explicit error naming the class. + */ + private function renderConstructor(string $fqcn, ?ClassMethod $constructor, SourceFile $file): string + { + $params = []; + $args = []; + foreach ($constructor?->params ?? [] as $param) { + $paramName = $param->var->name; + if ($param->byRef) { + $this->errors[] = "$fqcn::__construct: by-ref parameter \$$paramName cannot be proxied yet"; + } + if ($param->variadic) { + $this->errors[] = "$fqcn::__construct: variadic parameter \$$paramName cannot be proxied yet"; + } + $rendered = ''; + if ($param->type !== null) { + $rendered = $this->printer->renderType($param->type, $file) . ' '; + } + $rendered .= '$' . $paramName; + if ($param->default !== null) { + $rendered .= ' = ' . $this->printer->renderExpr($param->default, $file); + } + $params[] = $rendered; + $args[] = '$' . $paramName; + } + $call = "\\ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [" + . implode(', ', $args) . ']])'; + return " public function __construct(" . implode(', ', $params) . ")\n" + . " {\n" + . " [\$this->__rhandle, \$this->__epoch] = $call;\n" + . " \\ShirabeRustObjectRegistry::adopt(\$this->__rhandle, \$this);\n" + . " }"; + } + + /** * Static methods read no instance state; their real implementation is materialized * verbatim so they run locally in the worker. Non-public static helpers they call * (through `self::`, `static::` or the class name) are materialized along with them. diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list index 17f14615..8aece425 100644 --- a/scripts/plugin-stub-generator/targets.list +++ b/scripts/plugin-stub-generator/targets.list @@ -6,6 +6,8 @@ Composer\EventDispatcher\EventDispatcher Composer\Script\Event Composer\PartialComposer Composer\Composer +Composer\Config +Composer\Downloader\DownloadManager Composer\IO\BaseIO Composer\IO\ConsoleIO Composer\IO\BufferIO |
