aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-05 03:58:03 +0900
committernsfisis <nsfisis@gmail.com>2026-08-05 03:58:03 +0900
commit9b444a9a879b75a6af3d3c7ba8b9a4294574c3ec (patch)
treeb1041b55bc3ed36a391370cc2f5ececc8cb386b2
parentbe3458128ef09b3d1074b134f2822162e077e165 (diff)
downloadphp-shirabe-9b444a9a879b75a6af3d3c7ba8b9a4294574c3ec.tar.gz
php-shirabe-9b444a9a879b75a6af3d3c7ba8b9a4294574c3ec.tar.zst
php-shirabe-9b444a9a879b75a6af3d3c7ba8b9a4294574c3ec.zip
feat(plugin): run plugin-provided commands in a worker-side application
A same-FQCN Composer\Console\Application, hand-written under the new php/runtime/ tree, hosts CommandProvider commands inside the PHP worker: PhpCommandProxy overrides run() and forwards the stringified input, so the real Symfony machinery binds, validates and executes against the live command object, while help/list render Rust-side from a definition read back at construction. Reverse \Shirabe\RustCommandStub rows let a plugin command invoke built-in commands back in the Rust process, keeping every command on the side whose helper set it was written for. Composer\EventDispatcher\Event moves from a generated stub to a dual-mode runtime class: the real BaseCommand::initialize constructs a PreCommandRunEvent natively in the worker, which a proxy-only constructor guard rejected. Its PRE_COMMAND_RUN dispatch reaches a new EventDispatcher stub whose dispatch supports the observably-no-op no-listener case and fails explicitly otherwise. The stub generator now accepts runtime-provided classes as stub bases (never as targets) and cross-checks the Application handoff property table against the real class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
-rw-r--r--crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php142
-rw-r--r--crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php120
-rw-r--r--crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php34
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php67
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php92
-rw-r--r--crates/shirabe-php-rpc/php/worker.php70
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs24
-rw-r--r--crates/shirabe/src/console/application.rs102
-rw-r--r--crates/shirabe/src/plugin/php_plugin_proxy.rs437
-rw-r--r--docs/dev/php-rpc.md49
-rw-r--r--docs/dev/plugin-stub-generation.md19
-rwxr-xr-xscripts/plugin-stub-generator/generate-stubs61
-rw-r--r--scripts/plugin-stub-generator/src/Generator.php67
-rw-r--r--scripts/plugin-stub-generator/targets.list2
14 files changed, 1186 insertions, 100 deletions
diff --git a/crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php b/crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php
new file mode 100644
index 00000000..50ea31b1
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php
@@ -0,0 +1,142 @@
+<?php
+
+// Shirabe's own definition of Composer\Console\Application for the plugin worker. The real
+// class file is never autoloaded in this process: plugin-provided commands run here under a
+// genuine Symfony console application, and this class supplies the Composer-specific surface
+// (getIO()/getComposer()/...) backed by the Rust process over RPC. Defining the same FQCN
+// instead of subclassing the real class keeps both `instanceof` and strict `get_class()`
+// comparisons identical to upstream.
+//
+// Unlike the files under stubs/, the classes under runtime/ are hand-written: they are
+// two-world implementations with behavior of their own, not mechanical proxies, so the stub
+// generator does not manage them.
+
+namespace Composer\Console;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Symfony\Component\Console\Application as BaseApplication;
+use Symfony\Component\Console\Input\InputDefinition;
+use Symfony\Component\Console\Input\InputOption;
+
+class Application extends BaseApplication
+{
+ private static bool $shirabeConstructing = false;
+
+ /** @var Composer|null */
+ private $shirabeComposer = null;
+ /** @var IOInterface */
+ private $shirabeIo;
+ /** @var string|null */
+ private $shirabeInitialWorkingDirectory = null;
+ private bool $shirabeDisablePluginsByDefault = false;
+ private bool $shirabeDisableScriptsByDefault = false;
+
+ /**
+ * Builds the worker-side application from the Rust-side handoff. $config keys:
+ * `composer` (Composer proxy stub or null), `io` (IO proxy stub),
+ * `initialWorkingDirectory` (?string), `disablePluginsByDefault` (bool),
+ * `disableScriptsByDefault` (bool), `rustCommands` (metadata rows for
+ * \Shirabe\RustCommandStub), `pluginCommands` (live command entities from the P table).
+ */
+ public static function __shirabeBoot(array $config): self
+ {
+ self::$shirabeConstructing = true;
+ try {
+ $app = new self();
+ } finally {
+ self::$shirabeConstructing = false;
+ }
+ $app->setAutoExit(false);
+ // A command failure must reach the Rust side as an RPC throw, not get rendered here.
+ $app->setCatchExceptions(false);
+ $app->shirabeComposer = $config['composer'];
+ $app->shirabeIo = $config['io'];
+ $app->shirabeInitialWorkingDirectory = $config['initialWorkingDirectory'];
+ $app->shirabeDisablePluginsByDefault = $config['disablePluginsByDefault'];
+ $app->shirabeDisableScriptsByDefault = $config['disableScriptsByDefault'];
+ foreach ($config['rustCommands'] as $meta) {
+ $app->add(new \Shirabe\RustCommandStub($meta));
+ }
+ foreach ($config['pluginCommands'] as $command) {
+ $app->add($command);
+ }
+
+ return $app;
+ }
+
+ public function __construct(string $name = 'Composer', string $version = '')
+ {
+ if (!self::$shirabeConstructing) {
+ // TODO(plugin): a plugin constructing its own Composer\Console\Application would
+ // run commands outside the Rust-side orchestration; whether and how to support
+ // that is undecided, so fail loudly instead of handing out a half-wired instance.
+ throw new \RuntimeException(
+ 'Shirabe does not support constructing Composer\Console\Application inside the plugin process yet'
+ );
+ }
+ parent::__construct($name, $version !== '' ? $version : Composer::getVersion());
+ }
+
+ public function getIO(): IOInterface
+ {
+ return $this->shirabeIo;
+ }
+
+ public function getComposer(bool $required = true, ?bool $disablePlugins = null, ?bool $disableScripts = null): ?Composer
+ {
+ if (null === $this->shirabeComposer && $required) {
+ // TODO(plugin): creating a Composer instance from scratch here would need
+ // Factory::create over RPC; the handoff currently always carries the instance the
+ // Rust side already built, so this only fires after resetComposer-style flows.
+ throw new \RuntimeException(
+ 'Shirabe cannot create a new Composer instance inside the plugin process yet'
+ );
+ }
+
+ return $this->shirabeComposer;
+ }
+
+ public function resetComposer(): void
+ {
+ // TODO(plugin): the Composer instance is owned by the Rust process; dropping only the
+ // worker-side reference would desynchronize the two worlds, so this needs a
+ // reset-and-refetch round trip that does not exist yet.
+ throw new \RuntimeException(
+ 'Shirabe does not support resetComposer() inside the plugin process yet'
+ );
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getInitialWorkingDirectory()
+ {
+ return $this->shirabeInitialWorkingDirectory;
+ }
+
+ public function getDisablePluginsByDefault(): bool
+ {
+ return $this->shirabeDisablePluginsByDefault;
+ }
+
+ public function getDisableScriptsByDefault(): bool
+ {
+ return $this->shirabeDisableScriptsByDefault;
+ }
+
+ // Same as the real class: without Composer's global options in the definition, binding a
+ // forwarded command line that carries e.g. --working-dir would fail here even though the
+ // Rust side already consumed the option.
+ protected function getDefaultInputDefinition(): InputDefinition
+ {
+ $definition = parent::getDefaultInputDefinition();
+ $definition->addOption(new InputOption('--profile', null, InputOption::VALUE_NONE, 'Display timing and memory usage information'));
+ $definition->addOption(new InputOption('--no-plugins', null, InputOption::VALUE_NONE, 'Whether to disable plugins.'));
+ $definition->addOption(new InputOption('--no-scripts', null, InputOption::VALUE_NONE, 'Skips the execution of all scripts defined in composer.json file.'));
+ $definition->addOption(new InputOption('--working-dir', '-d', InputOption::VALUE_REQUIRED, 'If specified, use the given directory as working directory.'));
+ $definition->addOption(new InputOption('--no-cache', null, InputOption::VALUE_NONE, 'Prevent use of the cache'));
+
+ return $definition;
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php
new file mode 100644
index 00000000..0d35ac55
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php
@@ -0,0 +1,120 @@
+<?php
+
+// Shirabe's own definition of Composer\EventDispatcher\Event for the plugin worker. This class
+// 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.
+
+namespace Composer\EventDispatcher;
+
+class Event implements \ShirabeRustStub
+{
+ /** @var int|null Null in native mode. */
+ protected $__rhandle;
+ /** @var int */
+ protected $__epoch = 0;
+
+ /** @var string This event's name (native mode) */
+ protected $name;
+
+ /** @var string[] Arguments passed by the user, these will be forwarded to CLI script handlers (native mode) */
+ protected $args;
+
+ /** @var mixed[] Flags usable in PHP script handlers (native mode) */
+ protected $flags;
+
+ /** @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 = [])
+ {
+ 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 __destruct()
+ {
+ if ($this->__rhandle !== null) {
+ \ShirabeRustObjectRegistry::release($this->__rhandle);
+ }
+ }
+
+ public function __shirabeRustHandleDescriptor(): ?array
+ {
+ if ($this->__rhandle === null) {
+ return null;
+ }
+
+ return [
+ '__rhandle' => $this->__rhandle,
+ '__class' => static::class,
+ '__epoch' => $this->__epoch,
+ ];
+ }
+
+ public function getName(): string
+ {
+ if ($this->__rhandle !== null) {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getName', []);
+ }
+
+ return $this->name;
+ }
+
+ public function getArguments(): array
+ {
+ if ($this->__rhandle !== null) {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getArguments', []);
+ }
+
+ return $this->args;
+ }
+
+ public function getFlags(): array
+ {
+ if ($this->__rhandle !== null) {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getFlags', []);
+ }
+
+ return $this->flags;
+ }
+
+ public function isPropagationStopped(): bool
+ {
+ if ($this->__rhandle !== null) {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isPropagationStopped', []);
+ }
+
+ return $this->propagationStopped;
+ }
+
+ public function stopPropagation(): void
+ {
+ if ($this->__rhandle !== null) {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'stopPropagation', []);
+
+ return;
+ }
+ $this->propagationStopped = true;
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php b/crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php
new file mode 100644
index 00000000..23ebfc04
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php
@@ -0,0 +1,34 @@
+<?php
+
+// Worker-side stand-in for a Rust-implemented (built-in) command: it carries only the list
+// metadata, and running it forwards the raw input line back to the Rust process, where the
+// real implementation executes against its own application state (helper set included). This
+// is how a plugin command that invokes a built-in command crosses back over the process
+// boundary; each command always runs on the side that owns its implementation, so a helper
+// lookup always resolves against the helper set of the world the command was written for.
+
+namespace Shirabe;
+
+use Composer\Command\BaseCommand;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class RustCommandStub extends BaseCommand
+{
+ /** @param array{name: string, description: string, aliases: list<string>, hidden: bool} $meta */
+ public function __construct(array $meta)
+ {
+ parent::__construct($meta['name']);
+ $this->setDescription($meta['description']);
+ $this->setAliases($meta['aliases']);
+ $this->setHidden($meta['hidden']);
+ // The real input definition lives on the Rust side and the raw tokens are forwarded
+ // untouched, so nothing may be validated (or consumed) here.
+ $this->ignoreValidationErrors();
+ }
+
+ public function run(InputInterface $input, OutputInterface $output): int
+ {
+ return (int) \ShirabeRpcRuntime::callRust(0, '__shirabe_run_rust_command', [$this->getName(), (string) $input]);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
deleted file mode 100644
index fea84c75..00000000
--- a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
+++ /dev/null
@@ -1,67 +0,0 @@
-<?php
-
-// Generated by scripts/plugin-stub-generator; do not edit by hand.
-// Proxy stub for Composer\EventDispatcher\Event: the public surface forwards to the Rust-side entity over RPC.
-
-namespace Composer\EventDispatcher;
-
-class Event implements \ShirabeRustStub
-{
- /** @var int */
- protected $__rhandle;
- /** @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'
- );
- }
- $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 getName(): string
- {
- return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getName', []);
- }
-
- public function getArguments(): array
- {
- return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getArguments', []);
- }
-
- public function getFlags(): array
- {
- return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getFlags', []);
- }
-
- public function isPropagationStopped(): bool
- {
- return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isPropagationStopped', []);
- }
-
- public function stopPropagation(): void
- {
- \ShirabeRpcRuntime::callRust($this->__rhandle, 'stopPropagation', []);
- }
-}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php
new file mode 100644
index 00000000..c0651081
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php
@@ -0,0 +1,92 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\EventDispatcher\EventDispatcher: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\EventDispatcher;
+
+use Composer\DependencyResolver\Transaction;
+use Composer\DependencyResolver\Operation\OperationInterface;
+use Composer\Repository\RepositoryInterface;
+
+class EventDispatcher implements \ShirabeRustStub
+{
+ /** @var int */
+ protected $__rhandle;
+ /** @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'
+ );
+ }
+ $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 setRunScripts(bool $runScripts = true): self
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'setRunScripts', [$runScripts]);
+ return $this;
+ }
+
+ public function dispatch(?string $eventName, ?Event $event = null): int
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatch', [$eventName, $event]);
+ }
+
+ public function dispatchScript(string $eventName, bool $devMode = false, array $additionalArgs = [], array $flags = []): int
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatchScript', [$eventName, $devMode, $additionalArgs, $flags]);
+ }
+
+ public function dispatchPackageEvent(string $eventName, bool $devMode, RepositoryInterface $localRepo, array $operations, OperationInterface $operation): int
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatchPackageEvent', [$eventName, $devMode, $localRepo, $operations, $operation]);
+ }
+
+ public function dispatchInstallerEvent(string $eventName, bool $devMode, bool $executeOperations, Transaction $transaction): int
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatchInstallerEvent', [$eventName, $devMode, $executeOperations, $transaction]);
+ }
+
+ public function addListener(string $eventName, $listener, int $priority = 0): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'addListener', [$eventName, $listener, $priority]);
+ }
+
+ public function removeListener($listener): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'removeListener', [$listener]);
+ }
+
+ public function addSubscriber(EventSubscriberInterface $subscriber): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'addSubscriber', [$subscriber]);
+ }
+
+ public function hasEventListeners(Event $event): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'hasEventListeners', [$event]);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php
index 50e9ef76..241dad57 100644
--- a/crates/shirabe-php-rpc/php/worker.php
+++ b/crates/shirabe-php-rpc/php/worker.php
@@ -18,8 +18,13 @@ const SHIRABE_MAX_FRAME_LEN = 268435456; // 256 MiB, mirrored on the Rust side.
/** Marker interface every Rust-proxy stub class implements. */
interface ShirabeRustStub
{
- /** @return array{__rhandle: int, __class: string, __epoch: int} */
- public function __shirabeRustHandleDescriptor(): array;
+ /**
+ * Null when this instance holds no Rust handle: a dual-mode class (see php/runtime/) that
+ * was constructed natively in this process crosses the wire as a P-table entity instead.
+ *
+ * @return ?array{__rhandle: int, __class: string, __epoch: int}
+ */
+ public function __shirabeRustHandleDescriptor(): ?array;
}
/** Interns proxy stubs so the same Rust handle always yields the same stub instance. */
@@ -199,7 +204,11 @@ final class ShirabeRpcRuntime
public static function toWire($value)
{
if ($value instanceof ShirabeRustStub) {
- return $value->__shirabeRustHandleDescriptor();
+ $descriptor = $value->__shirabeRustHandleDescriptor();
+ if ($descriptor !== null) {
+ return $descriptor;
+ }
+ // A natively-constructed dual-mode instance falls through to the P table below.
}
if (is_object($value)) {
return ShirabePhpObjectRegistry::descriptor($value);
@@ -616,6 +625,61 @@ ShirabeRpcRuntime::$dispatch = [
}
return $obj->{$args[1]};
},
+ // Builds the worker-side Composer\Console\Application (the runtime/ definition, not the
+ // real class) from the Rust handoff; the caller keeps the returned handle and runs
+ // plugin-provided commands through __shirabe_run_console_application.
+ '__shirabe_console_application_boot' => static function ($args) {
+ return \Composer\Console\Application::__shirabeBoot($args[0]);
+ },
+ // Runs one command line (the stringified input of the Rust-side run) through a booted
+ // worker-side application; output goes to the inherited stdio, the exit code returns
+ // over the wire, and a command failure propagates as an RPC throw (catchExceptions is
+ // off on the booted application).
+ '__shirabe_run_console_application' => static function ($args) {
+ [$app, $inputString] = $args;
+ if (!$app instanceof \Composer\Console\Application) {
+ throw new RuntimeException('__shirabe_run_console_application expects an application handle');
+ }
+ return $app->run(new \Symfony\Component\Console\Input\StringInput($inputString));
+ },
+ // Reads a command's input definition (plus help text and extra usages) as plain data, so
+ // the Rust side can mirror it for `help`/`list` rendering without executing anything.
+ '__shirabe_read_command_definition' => static function ($args) {
+ $command = $args[0];
+ if (!$command instanceof \Symfony\Component\Console\Command\Command) {
+ throw new RuntimeException('__shirabe_read_command_definition expects a command handle');
+ }
+ $definition = $command->getDefinition();
+ $arguments = [];
+ foreach ($definition->getArguments() as $argument) {
+ $arguments[] = [
+ 'name' => $argument->getName(),
+ 'required' => $argument->isRequired(),
+ 'isArray' => $argument->isArray(),
+ 'description' => $argument->getDescription(),
+ 'default' => $argument->getDefault(),
+ ];
+ }
+ $options = [];
+ foreach ($definition->getOptions() as $option) {
+ $options[] = [
+ 'name' => $option->getName(),
+ 'shortcut' => $option->getShortcut(),
+ 'acceptValue' => $option->acceptValue(),
+ 'isValueRequired' => $option->isValueRequired(),
+ 'isArray' => $option->isArray(),
+ 'isNegatable' => $option->isNegatable(),
+ 'description' => $option->getDescription(),
+ 'default' => $option->getDefault(),
+ ];
+ }
+ return [
+ 'arguments' => $arguments,
+ 'options' => $options,
+ 'help' => $command->getHelp(),
+ 'usages' => $command->getUsages(),
+ ];
+ },
];
ShirabeRpcRuntime::serveForever();
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index ba5443d2..ef11d542 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -563,8 +563,8 @@ const GLUE_SCRIPT: &str = include_str!("../php/worker.php");
/// generator's `--check` mode verifies both the file contents and this list).
const STUB_FILES: &[(&str, &str)] = &[
(
- "Composer/EventDispatcher/Event.php",
- include_str!("../php/stubs/Composer/EventDispatcher/Event.php"),
+ "Composer/EventDispatcher/EventDispatcher.php",
+ include_str!("../php/stubs/Composer/EventDispatcher/EventDispatcher.php"),
),
(
"Composer/Script/Event.php",
@@ -640,6 +640,24 @@ const STUB_FILES: &[(&str, &str)] = &[
),
];
+/// Hand-written worker-side classes (two-world implementations with behavior of their own, not
+/// mechanical proxies), written into the same autoload directory as the generated stubs so the
+/// prepended stub autoloader resolves their FQCNs ahead of any real class file.
+const RUNTIME_FILES: &[(&str, &str)] = &[
+ (
+ "Composer/Console/Application.php",
+ include_str!("../php/runtime/Composer/Console/Application.php"),
+ ),
+ (
+ "Composer/EventDispatcher/Event.php",
+ include_str!("../php/runtime/Composer/EventDispatcher/Event.php"),
+ ),
+ (
+ "Shirabe/RustCommandStub.php",
+ include_str!("../php/runtime/Shirabe/RustCommandStub.php"),
+ ),
+];
+
struct Worker {
stream: UnixStream,
// Also queried for its exit status when a socket read/write fails, to tell a dead worker
@@ -707,7 +725,7 @@ fn spawn_worker() -> anyhow::Result<Worker> {
std::fs::write(&script_path, GLUE_SCRIPT)?;
let stubs_dir = tempdir.path().join("stubs");
- for (relative_path, contents) in STUB_FILES {
+ for (relative_path, contents) in STUB_FILES.iter().chain(RUNTIME_FILES) {
let path = stubs_dir.join(relative_path);
std::fs::create_dir_all(path.parent().expect("stub paths have a parent"))?;
std::fs::write(&path, contents)?;
diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs
index cf57a5bf..7be18796 100644
--- a/crates/shirabe/src/console/application.rs
+++ b/crates/shirabe/src/console/application.rs
@@ -595,6 +595,8 @@ impl Application {
) -> anyhow::Result<Vec<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>>> {
let mut commands: Vec<std::rc::Rc<std::cell::RefCell<dyn SymfonyCommand>>> = vec![];
+ crate::plugin::reset_pending_plugin_command_handles();
+
let mut composer = self.get_composer(false, Some(false), None)?;
if composer.is_none() {
let disable_plugins = if self.disable_plugins_by_default {
@@ -637,6 +639,42 @@ impl Application {
}),
);
}
+
+ if !commands.is_empty() {
+ // Publish the handoff the worker-side console application boots from when one
+ // of these commands actually runs: the shared object graph, plus metadata
+ // mirrors of the built-in commands (plugin commands are not registered yet, so
+ // this snapshot is exactly the Rust-implemented set).
+ let mut seen: Vec<*const ()> = Vec::new();
+ let mut rust_commands: Vec<crate::plugin::RustCommandMetadata> = Vec::new();
+ for command in self.commands.values() {
+ let ptr = std::rc::Rc::as_ptr(command) as *const ();
+ if seen.contains(&ptr) {
+ continue;
+ }
+ seen.push(ptr);
+ let command = command.borrow();
+ let Some(name) = command.get_name() else {
+ continue;
+ };
+ rust_commands.push(crate::plugin::RustCommandMetadata {
+ name,
+ description: command.get_description(),
+ aliases: command.get_aliases(),
+ hidden: command.is_hidden(),
+ });
+ }
+ crate::plugin::publish_console_application_context(
+ &composer,
+ &self.io,
+ self.get_initial_working_directory(),
+ self.disable_plugins_by_default,
+ self.disable_scripts_by_default,
+ rust_commands,
+ crate::plugin::take_pending_plugin_command_handles(),
+ );
+ register_worker_reverse_application(self.me.clone());
+ }
}
Ok(commands)
@@ -3122,3 +3160,67 @@ fn borrow_output_mut(
) -> std::cell::RefMut<'_, dyn OutputInterface> {
output.borrow_mut()
}
+
+thread_local! {
+ /// The application answering `__shirabe_run_rust_command` callbacks from the plugin
+ /// worker's reverse command stubs; registered when plugin commands are collected.
+ static WORKER_REVERSE_APPLICATION: std::cell::RefCell<
+ Option<std::rc::Weak<std::cell::RefCell<Application>>>,
+ > = const { std::cell::RefCell::new(None) };
+}
+
+/// Registers the application the worker's reverse command stubs call back into.
+pub(crate) fn register_worker_reverse_application(
+ application: std::rc::Weak<std::cell::RefCell<Application>>,
+) {
+ WORKER_REVERSE_APPLICATION.with(|slot| *slot.borrow_mut() = Some(application));
+}
+
+/// Runs a built-in command on behalf of a plugin-provided command executing in the worker
+/// (the reverse half of the two-world command split): the stringified input the plugin passed
+/// is re-parsed here and the command runs against this side's application state — helper set
+/// included — writing to the same stdio the worker inherited.
+pub(crate) fn run_worker_reverse_command(name: &str, input_line: &str) -> anyhow::Result<i64> {
+ let application = WORKER_REVERSE_APPLICATION
+ .with(|slot| slot.borrow().as_ref().and_then(std::rc::Weak::upgrade))
+ .ok_or_else(|| {
+ anyhow::anyhow!(shirabe_php_shim::RuntimeException {
+ message: format!(
+ "cannot run command {name}: no application is registered for worker callbacks"
+ ),
+ code: 0,
+ })
+ })?;
+ let command = application.borrow_mut().find(name)?;
+ // PHP's `(string) $input` omits the command name when the input was built without an
+ // explicit `command` entry, but the merged definition binds the first positional token to
+ // the `command` argument, so the name is prepended when the first token is not this
+ // command.
+ let trimmed = input_line.trim();
+ let first_token = trimmed.split_whitespace().next().unwrap_or("");
+ let is_named = {
+ let command = command.borrow();
+ command.get_name().as_deref() == Some(first_token)
+ || command
+ .get_aliases()
+ .iter()
+ .any(|alias| alias == first_token)
+ };
+ let line = if is_named {
+ trimmed.to_string()
+ } else if trimmed.is_empty() {
+ name.to_string()
+ } else {
+ format!("{name} {trimmed}")
+ };
+ let input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>> =
+ std::rc::Rc::new(std::cell::RefCell::new(
+ shirabe_external_packages::symfony::console::input::string_input::StringInput::new(
+ &line,
+ )?,
+ ));
+ let output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> = std::rc::Rc::new(
+ std::cell::RefCell::new(ConsoleOutput::new(None, None, None)?),
+ );
+ command.borrow().run(input, output)
+}
diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs
index 71182477..d13019e3 100644
--- a/crates/shirabe/src/plugin/php_plugin_proxy.rs
+++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs
@@ -42,6 +42,9 @@ enum RustEntity {
RepositoryManager(std::rc::Rc<std::cell::RefCell<dyn RepositoryManagerInterface>>),
Repository(RepositoryInterfaceHandle),
Package(std::rc::Rc<std::cell::RefCell<AnyPackage>>),
+ EventDispatcher(
+ std::rc::Rc<std::cell::RefCell<dyn crate::event_dispatcher::EventDispatcherInterface>>,
+ ),
}
/// The pointer identity backing R-table interning: the same shared instance must always cross
@@ -56,6 +59,9 @@ fn entity_ptr_id(entity: &RustEntity) -> usize {
RustEntity::RepositoryManager(rm) => std::rc::Rc::as_ptr(rm) as *const () as usize,
RustEntity::Repository(repository) => repository.ptr_id(),
RustEntity::Package(package) => std::rc::Rc::as_ptr(package) as *const () as usize,
+ RustEntity::EventDispatcher(dispatcher) => {
+ std::rc::Rc::as_ptr(dispatcher) as *const () as usize
+ }
}
}
@@ -251,6 +257,27 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> {
None => PluginValue::Null,
});
}
+ if method_name == "__shirabe_run_rust_command" {
+ let (name, input_line) = match (args.first(), args.get(1)) {
+ (Some(PluginValue::String(name)), Some(PluginValue::String(line))) => (
+ // TODO(phase-e): lossy UTF-8; command lines are bytes in PHP.
+ String::from_utf8_lossy(name).into_owned(),
+ String::from_utf8_lossy(line).into_owned(),
+ ),
+ _ => {
+ return Err(runtime_throw(format!(
+ "__shirabe_run_rust_command expects a command name and an input line, got {args:?}"
+ )));
+ }
+ };
+ return match crate::console::application::run_worker_reverse_command(
+ &name,
+ &input_line,
+ ) {
+ Ok(code) => Ok(PluginValue::Int(code)),
+ Err(e) => Err(runtime_throw(format!("{e:#}"))),
+ };
+ }
return Err(runtime_throw(format!(
"unknown runtime service method `{method_name}`"
)));
@@ -282,6 +309,9 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> {
Some(RustEntity::Package(package)) => {
dispatch_package_method(&package, method_name, &args)
}
+ Some(RustEntity::EventDispatcher(dispatcher)) => {
+ dispatch_event_dispatcher_method(&dispatcher, method_name, &args)
+ }
None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))),
}
}
@@ -312,6 +342,14 @@ fn dispatch_composer_method(
let package = composer.borrow().get_package().as_rc().clone();
package_handle_value(&package)
}
+ "getEventDispatcher" => {
+ let dispatcher = composer.borrow().get_event_dispatcher();
+ let rhandle = register_entity(RustEntity::EventDispatcher(dispatcher));
+ Ok(rust_handle_value(
+ rhandle,
+ "Composer\\EventDispatcher\\EventDispatcher",
+ ))
+ }
// TODO(plugin): the remaining Composer object graph (getConfig, getLocker, ...)
// becomes reachable over RPC on demand, driven by explicit errors from real plugins.
other => Err(runtime_throw(format!(
@@ -320,6 +358,41 @@ fn dispatch_composer_method(
}
}
+fn dispatch_event_dispatcher_method(
+ dispatcher: &std::rc::Rc<
+ std::cell::RefCell<dyn crate::event_dispatcher::EventDispatcherInterface>,
+ >,
+ method_name: &str,
+ args: &[PluginValue],
+) -> Result<PluginValue, PhpThrow> {
+ match method_name {
+ "dispatch" => {
+ let name = match args.first() {
+ Some(PluginValue::String(name)) => String::from_utf8_lossy(name).into_owned(),
+ other => {
+ return Err(runtime_throw(format!(
+ "dispatch expects an event name, got {other:?}"
+ )));
+ }
+ };
+ let probe = crate::event_dispatcher::Event::from_name(name.clone());
+ if dispatcher.borrow_mut().has_event_listeners(&probe) {
+ // TODO(plugin): dispatching a worker-constructed event through the Rust-side
+ // dispatcher needs the event object (and the console input it carries) proxied
+ // back into this process; until then only the no-listener case — where
+ // upstream's dispatch is observably a no-op returning 0 — is supported.
+ return Err(runtime_throw(format!(
+ "dispatching `{name}` from the plugin process is not supported yet while listeners are registered for it"
+ )));
+ }
+ Ok(PluginValue::Int(0))
+ }
+ other => Err(runtime_throw(format!(
+ "the EventDispatcher method `{other}` is not available over RPC yet"
+ ))),
+ }
+}
+
fn dispatch_repository_manager_method(
rm: &std::rc::Rc<std::cell::RefCell<dyn RepositoryManagerInterface>>,
method_name: &str,
@@ -971,22 +1044,186 @@ impl Drop for PhpCommandProviderProxy {
}
}
+/// Metadata row for one Rust-implemented command, mirrored into the worker as a
+/// `\Shirabe\RustCommandStub` so a plugin-provided command can `find()` and invoke built-in
+/// commands (their execution crosses back into this process).
+#[derive(Debug)]
+pub(crate) struct RustCommandMetadata {
+ pub(crate) name: String,
+ pub(crate) description: String,
+ pub(crate) aliases: Vec<String>,
+ pub(crate) hidden: bool,
+}
+
+impl RustCommandMetadata {
+ fn wire_value(&self) -> PluginValue {
+ let mut row: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ row.insert(b"name".to_vec(), PluginValue::string(self.name.clone()));
+ row.insert(
+ b"description".to_vec(),
+ PluginValue::string(self.description.clone()),
+ );
+ row.insert(
+ b"aliases".to_vec(),
+ PluginValue::List(
+ self.aliases
+ .iter()
+ .map(|alias| PluginValue::string(alias.clone()))
+ .collect(),
+ ),
+ );
+ row.insert(b"hidden".to_vec(), PluginValue::Bool(self.hidden));
+ PluginValue::Array(row)
+ }
+}
+
+/// Handoff state for the worker-side console application (the `Composer\Console\Application`
+/// defined under the RPC crate's `php/runtime/`): assembled by
+/// `Application::get_plugin_commands` once the full command set is known, booted in the worker
+/// the first time a plugin-provided command actually runs.
+#[derive(Debug)]
+pub(crate) struct PhpConsoleApplicationContext {
+ composer: ComposerHandle,
+ io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
+ initial_working_directory: Option<String>,
+ disable_plugins_by_default: bool,
+ disable_scripts_by_default: bool,
+ rust_commands: Vec<RustCommandMetadata>,
+ /// Clones of the plugin command handles; ownership (and release) stays with the
+ /// `PhpCommandProxy` instances holding the originals.
+ plugin_commands: Vec<PhpObjHandle>,
+ app: std::cell::RefCell<Option<PhpObjHandle>>,
+}
+
+thread_local! {
+ /// Handles of the `PhpCommandProxy` instances built while `Application::get_plugin_commands`
+ /// collects providers; drained into the context it publishes.
+ static PENDING_COMMAND_HANDLES: std::cell::RefCell<Vec<PhpObjHandle>> =
+ const { std::cell::RefCell::new(Vec::new()) };
+
+ /// The published context, read by `PhpCommandProxy::run` at execution time.
+ static CONSOLE_APP_CONTEXT: std::cell::RefCell<Option<std::rc::Rc<PhpConsoleApplicationContext>>> =
+ const { std::cell::RefCell::new(None) };
+}
+
+/// Clears handles a failed earlier collection may have left behind.
+pub(crate) fn reset_pending_plugin_command_handles() {
+ PENDING_COMMAND_HANDLES.with(|handles| handles.borrow_mut().clear());
+}
+
+pub(crate) fn take_pending_plugin_command_handles() -> Vec<PhpObjHandle> {
+ PENDING_COMMAND_HANDLES.with(|handles| std::mem::take(&mut *handles.borrow_mut()))
+}
+
+pub(crate) fn publish_console_application_context(
+ composer: &ComposerHandle,
+ io: &std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
+ initial_working_directory: Option<String>,
+ disable_plugins_by_default: bool,
+ disable_scripts_by_default: bool,
+ rust_commands: Vec<RustCommandMetadata>,
+ plugin_commands: Vec<PhpObjHandle>,
+) {
+ let context = std::rc::Rc::new(PhpConsoleApplicationContext {
+ composer: composer.clone(),
+ io: io.clone(),
+ initial_working_directory,
+ disable_plugins_by_default,
+ disable_scripts_by_default,
+ rust_commands,
+ plugin_commands,
+ app: std::cell::RefCell::new(None),
+ });
+ CONSOLE_APP_CONTEXT.with(|slot| *slot.borrow_mut() = Some(context));
+}
+
+impl PhpConsoleApplicationContext {
+ /// Boots the worker-side application on first use and returns its handle.
+ fn booted_app(&self) -> anyhow::Result<PhpObjHandle> {
+ if let Some(app) = self.app.borrow().as_ref() {
+ return Ok(app.clone());
+ }
+ let mut config: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ config.insert(b"composer".to_vec(), composer_handle_value(&self.composer));
+ config.insert(b"io".to_vec(), io_handle_value(&self.io)?);
+ config.insert(
+ b"initialWorkingDirectory".to_vec(),
+ match &self.initial_working_directory {
+ Some(dir) => PluginValue::string(dir.clone()),
+ None => PluginValue::Null,
+ },
+ );
+ config.insert(
+ b"disablePluginsByDefault".to_vec(),
+ PluginValue::Bool(self.disable_plugins_by_default),
+ );
+ config.insert(
+ b"disableScriptsByDefault".to_vec(),
+ PluginValue::Bool(self.disable_scripts_by_default),
+ );
+ config.insert(
+ b"rustCommands".to_vec(),
+ PluginValue::List(
+ self.rust_commands
+ .iter()
+ .map(RustCommandMetadata::wire_value)
+ .collect(),
+ ),
+ );
+ config.insert(
+ b"pluginCommands".to_vec(),
+ PluginValue::List(
+ self.plugin_commands
+ .iter()
+ .cloned()
+ .map(PluginValue::PhpHandle)
+ .collect(),
+ ),
+ );
+ let value = unwrap_php_result(call_function_with_dispatcher(
+ "__shirabe_console_application_boot",
+ vec![PluginValue::Array(config)],
+ Some(&mut PluginRpcDispatcher::default()),
+ ))?;
+ let app = match value {
+ PluginValue::PhpHandle(app) => app,
+ other => {
+ return Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException {
+ message: format!(
+ "__shirabe_console_application_boot returned an unsupported shape over RPC: {other:?}"
+ ),
+ code: 0,
+ }));
+ }
+ };
+ *self.app.borrow_mut() = Some(app.clone());
+ Ok(app)
+ }
+}
+
+impl Drop for PhpConsoleApplicationContext {
+ fn drop(&mut self) {
+ if let Some(app) = self.app.borrow_mut().take() {
+ let _ = release_php_handle(app.phandle);
+ }
+ }
+}
+
/// `BaseCommand` adapter for a command entity living in the PHP child process. The Rust-side
-/// command state mirrors the child's list metadata (name, description, aliases, hidden flag —
-/// read back over RPC at construction, after the PHP constructor ran `configure()`); running
-/// the command needs the PHP-side Symfony Application and is an explicit error until that
-/// exists.
+/// command state mirrors the child's metadata (name, description, aliases, hidden/proxy flags,
+/// help, usages and the input definition — read back over RPC at construction, after the PHP
+/// constructor ran `configure()`), so `list` and `help` render from local state; running the
+/// command forwards the whole input line to the worker-side console application.
#[derive(Debug)]
pub struct PhpCommandProxy {
base_command_data: crate::command::BaseCommandData,
handle: PhpObjHandle,
+ proxy_command: bool,
}
impl PhpCommandProxy {
pub(crate) fn new(handle: PhpObjHandle) -> anyhow::Result<Self> {
let data = crate::command::BaseCommandData::new(None);
- // TODO(plugin): the input definition (arguments/options) is not read back yet;
- // `help` rendering and input parsing for this command need it.
let name = Self::call_metadata_getter(&handle, "getName")?;
match name {
PluginValue::Null => {}
@@ -1025,12 +1262,156 @@ impl PhpCommandProxy {
}
other => return Err(Self::unsupported_shape(&handle, "isHidden", &other)),
}
+ let proxy_command = match Self::call_metadata_getter(&handle, "isProxyCommand")? {
+ PluginValue::Bool(proxy_command) => proxy_command,
+ other => return Err(Self::unsupported_shape(&handle, "isProxyCommand", &other)),
+ };
+ Self::read_back_definition(&handle, &data)?;
+ PENDING_COMMAND_HANDLES.with(|handles| handles.borrow_mut().push(handle.clone()));
Ok(Self {
base_command_data: data,
handle,
+ proxy_command,
})
}
+ /// Mirrors the command's input definition (plus help text and extra usages) into the
+ /// Rust-side command state, so `help`/`list` render it without touching the worker.
+ fn read_back_definition(
+ handle: &PhpObjHandle,
+ data: &crate::command::BaseCommandData,
+ ) -> anyhow::Result<()> {
+ use shirabe_external_packages::symfony::console::input::input_argument::InputArgument;
+ use shirabe_external_packages::symfony::console::input::input_definition::{
+ DefinitionItem, InputDefinition,
+ };
+ use shirabe_external_packages::symfony::console::input::input_option::InputOption;
+
+ let value = unwrap_php_result(call_function_with_dispatcher(
+ "__shirabe_read_command_definition",
+ vec![PluginValue::PhpHandle(handle.clone())],
+ Some(&mut PluginRpcDispatcher::default()),
+ ))?;
+ let mut map = match value {
+ PluginValue::Array(map) => map,
+ other => return Err(Self::unsupported_shape(handle, "getDefinition", &other)),
+ };
+ let field = |row: &mut IndexMap<Vec<u8>, PluginValue>, key: &str| -> PluginValue {
+ row.shift_remove(key.as_bytes())
+ .unwrap_or(PluginValue::Null)
+ };
+ let as_rows = |value: PluginValue| -> Vec<PluginValue> {
+ match value {
+ PluginValue::List(rows) => rows,
+ PluginValue::Array(map) => map.into_values().collect(),
+ _ => Vec::new(),
+ }
+ };
+
+ let mut items: Vec<DefinitionItem> = Vec::new();
+ for row in as_rows(field(&mut map, "arguments")) {
+ let mut row = match row {
+ PluginValue::Array(row) => row,
+ other => return Err(Self::unsupported_shape(handle, "getDefinition", &other)),
+ };
+ let (name, description) =
+ match (field(&mut row, "name"), field(&mut row, "description")) {
+ (PluginValue::String(name), PluginValue::String(description)) => (
+ String::from_utf8_lossy(&name).into_owned(),
+ String::from_utf8_lossy(&description).into_owned(),
+ ),
+ (other, _) => {
+ return Err(Self::unsupported_shape(handle, "getDefinition", &other));
+ }
+ };
+ let required = matches!(field(&mut row, "required"), PluginValue::Bool(true));
+ let is_array = matches!(field(&mut row, "isArray"), PluginValue::Bool(true));
+ let mut mode = if required {
+ InputArgument::REQUIRED
+ } else {
+ InputArgument::OPTIONAL
+ };
+ if is_array {
+ mode |= InputArgument::IS_ARRAY;
+ }
+ let default = field(&mut row, "default").to_php_mixed()?;
+ items.push(DefinitionItem::InputArgument(InputArgument::new(
+ name,
+ Some(mode),
+ description,
+ default,
+ )?));
+ }
+ for row in as_rows(field(&mut map, "options")) {
+ let mut row = match row {
+ PluginValue::Array(row) => row,
+ other => return Err(Self::unsupported_shape(handle, "getDefinition", &other)),
+ };
+ let (name, description) =
+ match (field(&mut row, "name"), field(&mut row, "description")) {
+ (PluginValue::String(name), PluginValue::String(description)) => (
+ String::from_utf8_lossy(&name).into_owned(),
+ String::from_utf8_lossy(&description).into_owned(),
+ ),
+ (other, _) => {
+ return Err(Self::unsupported_shape(handle, "getDefinition", &other));
+ }
+ };
+ let accept_value = matches!(field(&mut row, "acceptValue"), PluginValue::Bool(true));
+ let mut mode = if accept_value {
+ if matches!(field(&mut row, "isValueRequired"), PluginValue::Bool(true)) {
+ InputOption::VALUE_REQUIRED
+ } else {
+ InputOption::VALUE_OPTIONAL
+ }
+ } else {
+ InputOption::VALUE_NONE
+ };
+ if matches!(field(&mut row, "isArray"), PluginValue::Bool(true)) {
+ mode |= InputOption::VALUE_IS_ARRAY;
+ }
+ if matches!(field(&mut row, "isNegatable"), PluginValue::Bool(true)) {
+ mode |= InputOption::VALUE_NEGATABLE;
+ }
+ let shortcut = field(&mut row, "shortcut").to_php_mixed()?;
+ // `getDefault()` exposes the stored representation (`false` for VALUE_NONE), while
+ // the constructor only accepts null there; mirror the constructor's normalization.
+ let default = if accept_value {
+ field(&mut row, "default").to_php_mixed()?
+ } else {
+ PhpMixed::Null
+ };
+ items.push(DefinitionItem::InputOption(InputOption::new(
+ &name,
+ shortcut,
+ Some(mode),
+ description,
+ default,
+ )?));
+ }
+ data.command_data().set_definition(
+ shirabe_external_packages::symfony::console::command::command::SetDefinitionArg::Definition(
+ InputDefinition::new(items)?,
+ ),
+ );
+
+ match field(&mut map, "help") {
+ PluginValue::String(help) => {
+ Command::set_help(data, &String::from_utf8_lossy(&help));
+ }
+ other => return Err(Self::unsupported_shape(handle, "getHelp", &other)),
+ }
+ for usage in as_rows(field(&mut map, "usages")) {
+ match usage {
+ PluginValue::String(usage) => {
+ Command::add_usage(data, &String::from_utf8_lossy(&usage));
+ }
+ other => return Err(Self::unsupported_shape(handle, "getUsages", &other)),
+ }
+ }
+ Ok(())
+ }
+
fn call_metadata_getter(handle: &PhpObjHandle, method: &str) -> anyhow::Result<PluginValue> {
unwrap_php_result(call_php_method(
handle.phandle,
@@ -1056,22 +1437,60 @@ impl PhpCommandProxy {
}
impl Command for PhpCommandProxy {
+ /// Forwards the whole run to the worker-side console application (the proxy-command idiom
+ /// the trait allows): the real Symfony machinery there performs input binding, validation,
+ /// interaction and execution against the live PHP command object, writing to the stdio the
+ /// worker inherited. The Rust-side `base_run` half must not run against the mirrored
+ /// definition, or binding and interaction would happen twice.
+ fn run(
+ &self,
+ input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>,
+ _output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ ) -> anyhow::Result<i64> {
+ let context = CONSOLE_APP_CONTEXT
+ .with(|slot| slot.borrow().clone())
+ .ok_or_else(|| {
+ anyhow::anyhow!(shirabe_php_shim::RuntimeException {
+ message: format!(
+ "cannot run plugin-provided command {}: no worker-side console application context was published",
+ self.handle.class
+ ),
+ code: 0,
+ })
+ })?;
+ let app = context.booted_app()?;
+ let input_line = input.borrow().__to_string();
+ let value = unwrap_php_result(call_function_with_dispatcher(
+ "__shirabe_run_console_application",
+ vec![PluginValue::PhpHandle(app), PluginValue::string(input_line)],
+ Some(&mut PluginRpcDispatcher::default()),
+ ))?;
+ match value {
+ PluginValue::Int(code) => Ok(code),
+ other => Err(Self::unsupported_shape(&self.handle, "run", &other)),
+ }
+ }
+
fn execute(
&self,
_input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>,
_output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
) -> anyhow::Result<i64> {
- // TODO(plugin): executing a plugin-provided command requires the PHP-side Symfony
- // Application; until then this is an explicit error, never a silent no-op.
+ // `run` above never reaches this template hook; a direct call would bypass the
+ // worker-side binding, so it stays an explicit error.
Err(anyhow::anyhow!(shirabe_php_shim::RuntimeException {
message: format!(
- "cannot execute plugin-provided command {} yet: running PHP commands is not supported",
+ "plugin-provided command {} executes in the PHP worker through run(); execute() must not be called directly",
self.handle.class
),
code: 0,
}))
}
+ fn is_proxy_command(&self) -> bool {
+ self.proxy_command
+ }
+
shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data);
}
diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md
index a1093ec5..298b4059 100644
--- a/docs/dev/php-rpc.md
+++ b/docs/dev/php-rpc.md
@@ -128,18 +128,47 @@ function; an unknown name is an explicit error. Notable internal helpers:
even autoloadable there, i.e. no Composer PHP runtime and therefore no observer code.
- `__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`
+ (the `php/runtime/` definition) from the Rust handoff: the shared `$composer`/`$io` proxies,
+ the initial working directory and disable-by-default flags, `\Shirabe\RustCommandStub` rows
+ mirroring the built-in commands, and the live plugin-provided command entities.
+- `__shirabe_run_console_application` — runs one stringified command line through a booted
+ worker-side application; output goes to the inherited stdio, the exit code returns over the
+ wire, and a failure propagates as a Throw (the booted application does not catch exceptions).
+- `__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.
-## Proxy stubs
+The runtime service endpoint (handle 0) answers `__shirabe_find_file` (autoload lookups) and
+`__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.
-`php/stubs/` holds the proxy stub classes (`Composer\EventDispatcher\Event`,
-`Composer\Script\Event`, `Composer\PartialComposer`, `Composer\Composer`, and the
-`Composer\IO\{BaseIO,ConsoleIO,BufferIO,NullIO}` hierarchy). They are generated by
-`scripts/plugin-stub-generator/generate-stubs` and must not be edited by hand; see
-`docs/dev/plugin-stub-generation.md`. They are autoloaded with highest priority so a proxied FQCN 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`.
+## Proxy stubs and runtime classes
+
+`php/stubs/` holds the proxy stub classes (`Composer\Script\Event`, `Composer\PartialComposer`,
+`Composer\Composer`, the `Composer\IO\{BaseIO,ConsoleIO,BufferIO,NullIO}` hierarchy, the
+package/repository graph, and `Composer\EventDispatcher\EventDispatcher`). They are generated
+by `scripts/plugin-stub-generator/generate-stubs` and must not be edited by hand; see
+`docs/dev/plugin-stub-generation.md`.
+
+`php/runtime/` holds hand-written worker-side classes that are not mechanical proxies:
+
+- `Composer\Console\Application` — a same-FQCN two-world implementation (never the real class
+ file): plugin-provided commands run under it inside the worker, and its Composer-specific
+ 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).
+
+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`.
## The P table
diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md
index 09bb5625..e10c0e9c 100644
--- a/docs/dev/plugin-stub-generation.md
+++ b/docs/dev/plugin-stub-generation.md
@@ -25,6 +25,12 @@ Inputs:
* `targets.list` — the FQCNs to emit, stub base classes before their subclasses.
Growing the stub set means adding a line here and regenerating.
+* the hand-written classes under `crates/shirabe-php-rpc/php/runtime/` (their
+ FQCNs derive from the file paths). These are two-world implementations with
+ behavior of their own — not mechanical proxies — so the generator never emits
+ them, but it accepts them as base classes of generated stubs (computing the
+ inherited surface from the real Composer class the runtime file mirrors) and
+ fails if a `targets.list` entry would shadow one.
* the Composer checkout (`composer/`, override with `--composer-root=`); the
generator locates sources through the checkout's own PSR-4 autoload map, so
interfaces from vendor packages (e.g. `Psr\Log\LoggerInterface`) resolve too.
@@ -83,12 +89,19 @@ Generation fails — instead of emitting something quietly wrong — on:
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
- not a target,
+ neither a target nor provided by `php/runtime/`,
+* a target whose FQCN is also provided by `php/runtime/`,
* non-public class constants (materializing them is unsupported so far).
`generate-stubs` (in both modes) additionally fails when a `.php` file exists
-under the stubs directory that no target produces, or when `STUB_FILES` in
-`crates/shirabe-php-rpc/src/lib.rs` does not embed every generated file.
+under the stubs directory that no target produces, or when `STUB_FILES` /
+`RUNTIME_FILES` in `crates/shirabe-php-rpc/src/lib.rs` does not embed every
+generated stub / runtime file. It also cross-checks the handoff property table
+for `Composer\Console\Application` (declared in `generate-stubs` itself) against
+the real class: a property upstream adds without a handoff classification — or a
+table row the class no longer declares — fails generation, so the worker-side
+runtime application can never silently drop plugin-visible state after a
+Composer version bump.
When a future Composer release adds a public member the emitter cannot handle,
these assertions surface it at generation time; extending the emitter (or
diff --git a/scripts/plugin-stub-generator/generate-stubs b/scripts/plugin-stub-generator/generate-stubs
index c6b03c97..934577f3 100755
--- a/scripts/plugin-stub-generator/generate-stubs
+++ b/scripts/plugin-stub-generator/generate-stubs
@@ -7,6 +7,7 @@ require __DIR__ . '/vendor/autoload.php';
use Shirabe\PluginStubGenerator\GenerationError;
use Shirabe\PluginStubGenerator\Generator;
+use Shirabe\PluginStubGenerator\Project;
use Shirabe\PluginStubGenerator\Report;
$repoRoot = dirname(__DIR__, 2);
@@ -39,8 +40,29 @@ foreach (file(__DIR__ . '/targets.list', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY
}
}
+// Hand-written dual-mode classes (php/runtime/) resolve through the same worker autoloader as
+// the generated stubs; the generator must know them so they can serve as stub base classes and
+// so a targets.list entry can never shadow one.
+$runtimeDir = $repoRoot . '/crates/shirabe-php-rpc/php/runtime';
+$runtimeProvided = [];
+$runtimeFiles = [];
+if (is_dir($runtimeDir)) {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($runtimeDir, FilesystemIterator::SKIP_DOTS)
+ );
+ foreach ($iterator as $entry) {
+ if ($entry->isFile() && str_ends_with($entry->getFilename(), '.php')) {
+ $relative = substr($entry->getPathname(), strlen($runtimeDir) + 1);
+ $runtimeFiles[] = $relative;
+ $runtimeProvided[] = str_replace('/', '\\', substr($relative, 0, -strlen('.php')));
+ }
+ }
+}
+sort($runtimeProvided);
+sort($runtimeFiles);
+
try {
- $generator = new Generator($composerRoot, Report::load($reportPath), $targets);
+ $generator = new Generator($composerRoot, Report::load($reportPath), $targets, $runtimeProvided);
$files = $generator->generate();
} catch (GenerationError $e) {
foreach ($e->errors as $error) {
@@ -51,6 +73,38 @@ try {
$problems = [];
+// Handoff classification of Composer\Console\Application's declared properties. The worker-side
+// runtime definition (php/runtime/Composer/Console/Application.php) hands off exactly the state
+// a plugin-visible application exposes; a property the upstream class declares and this table
+// does not know means the handoff decision has not been made, and generation fails so a
+// Composer version bump surfaces it explicitly instead of silently dropping state.
+$applicationPropertyTable = [
+ 'composer' => 'handoff', // carried by __shirabe_console_application_boot
+ 'io' => 'handoff', // the IO seam proxy
+ 'initialWorkingDirectory' => 'handoff', // copied once at boot
+ 'disablePluginsByDefault' => 'handoff', // copied once at boot
+ 'disableScriptsByDefault' => 'handoff', // copied once at boot
+ 'hasPluginCommands' => 'rust-local', // Rust-side runtime state, never shared
+ 'logo' => 'static-config', // static rendering data, the worker never needs it
+];
+$applicationFile = (new Project($composerRoot))->sourceFor('Composer\\Console\\Application');
+$declaredProperties = [];
+foreach ($applicationFile->classLike->getProperties() as $property) {
+ foreach ($property->props as $prop) {
+ $declaredProperties[$prop->name->toString()] = true;
+ }
+}
+foreach ($declaredProperties as $name => $_) {
+ if (!isset($applicationPropertyTable[$name])) {
+ $problems[] = "Composer\\Console\\Application::\$$name is not classified in the handoff property table in generate-stubs";
+ }
+}
+foreach ($applicationPropertyTable as $name => $_) {
+ if (!isset($declaredProperties[$name])) {
+ $problems[] = "the handoff property table in generate-stubs classifies Composer\\Console\\Application::\$$name, which the class no longer declares";
+ }
+}
+
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($stubsDir, FilesystemIterator::SKIP_DOTS)
);
@@ -70,6 +124,11 @@ foreach ($files as $relative => $_) {
$problems[] = "STUB_FILES in $libRs does not embed $relative";
}
}
+foreach ($runtimeFiles as $relative) {
+ if (!str_contains($libRsText, "include_str!(\"../php/runtime/$relative\")")) {
+ $problems[] = "RUNTIME_FILES in $libRs does not embed $relative";
+ }
+}
if ($check) {
foreach ($files as $relative => $content) {
diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php
index b7cfc859..1e41eed8 100644
--- a/scripts/plugin-stub-generator/src/Generator.php
+++ b/scripts/plugin-stub-generator/src/Generator.php
@@ -93,22 +93,40 @@ final class Generator
*/
private array $surfaces = [];
- /** @param list<string> $targets */
+ /** @var array<string, true> */
+ private array $runtimeSet = [];
+
+ /**
+ * @param list<string> $targets
+ * @param list<string> $runtimeProvided FQCNs of the hand-written dual-mode classes under
+ * php/runtime/; they may serve as stub base classes
+ * but must never be generation targets themselves
+ */
public function __construct(
string $composerRoot,
private readonly Report $report,
private readonly array $targets,
+ private readonly array $runtimeProvided = [],
) {
$this->project = new Project($composerRoot);
$this->printer = new NamePrinter();
foreach ($targets as $fqcn) {
$this->targetSet[$fqcn] = true;
}
+ foreach ($runtimeProvided as $fqcn) {
+ $this->runtimeSet[$fqcn] = true;
+ }
}
/** @return array<string, string> relative stub path => file content */
public function generate(): array
{
+ foreach ($this->runtimeProvided as $fqcn) {
+ if (isset($this->targetSet[$fqcn])) {
+ $this->errors[] = "$fqcn is both a stub target and provided by php/runtime/;"
+ . ' the runtime definition would be shadowed by the generated stub';
+ }
+ }
$files = [];
foreach ($this->targets as $fqcn) {
$files[str_replace('\\', '/', $fqcn) . '.php'] = $this->emitClass($fqcn);
@@ -119,6 +137,45 @@ final class Generator
return $files;
}
+ /**
+ * The stub surface a runtime-provided (hand-written, dual-mode) base class exposes,
+ * computed from the real Composer class the runtime file mirrors, so a generated subclass
+ * stub can omit the methods it inherits — the same records emitClass builds for generated
+ * parents.
+ *
+ * @return array<string, list<array{string, bool, string, bool, bool}>>
+ */
+ private function surfaceFromRealClass(string $fqcn): array
+ {
+ $file = $this->project->sourceFor($fqcn);
+ $class = $file->classLike;
+ if (!$class instanceof Class_) {
+ $this->errors[] = "$fqcn is not a class";
+ return [];
+ }
+ $surface = [];
+ $parentFqcn = $class->extends === null ? null : $this->resolvedName($class->extends);
+ if ($parentFqcn !== null) {
+ $surface = $this->surfaces[$parentFqcn] ?? $this->surfaceFromRealClass($parentFqcn);
+ }
+ foreach ($this->interfaceClosure($class) as $interface) {
+ foreach ($interface->classLike->getMethods() as $method) {
+ if ($method->isStatic()) {
+ continue;
+ }
+ $surface[$method->name->toString()] ??= $this->fingerprint($method, $file);
+ }
+ }
+ foreach ($class->getMethods() as $method) {
+ $name = $method->name->toString();
+ if ($method->isStatic() || !$method->isPublic() || str_starts_with($name, '__')) {
+ continue;
+ }
+ $surface[$name] = $this->fingerprint($method, $file);
+ }
+ return $surface;
+ }
+
private function emitClass(string $fqcn): string
{
$file = $this->project->sourceFor($fqcn);
@@ -136,8 +193,12 @@ final class Generator
$parentFqcn = $class->extends === null ? null : $this->resolvedName($class->extends);
$isRoot = $parentFqcn === null;
if ($parentFqcn !== null && !isset($this->targetSet[$parentFqcn])) {
- $this->errors[] = "$fqcn extends $parentFqcn, which is not a stub target";
- $isRoot = true;
+ if (isset($this->runtimeSet[$parentFqcn])) {
+ $this->surfaces[$parentFqcn] ??= $this->surfaceFromRealClass($parentFqcn);
+ } else {
+ $this->errors[] = "$fqcn extends $parentFqcn, which is not a stub target";
+ $isRoot = true;
+ }
}
if (!$isRoot && !isset($this->surfaces[$parentFqcn])) {
$this->errors[] = "$fqcn must come after its base class $parentFqcn in targets.list";
diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list
index 59656c6a..17f14615 100644
--- a/scripts/plugin-stub-generator/targets.list
+++ b/scripts/plugin-stub-generator/targets.list
@@ -2,7 +2,7 @@
# line; the output path is derived from the FQCN. Every entry must be classified as
# rust-proxy or contract in the classifier report, and stub base classes must precede
# their subclasses.
-Composer\EventDispatcher\Event
+Composer\EventDispatcher\EventDispatcher
Composer\Script\Event
Composer\PartialComposer
Composer\Composer