aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/php/runtime
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 /crates/shirabe-php-rpc/php/runtime
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>
Diffstat (limited to 'crates/shirabe-php-rpc/php/runtime')
-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
3 files changed, 296 insertions, 0 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]);
+ }
+}