From 9b444a9a879b75a6af3d3c7ba8b9a4294574c3ec Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 5 Aug 2026 03:58:03 +0900 Subject: 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 --- .../php/runtime/Composer/Console/Application.php | 142 +++++++ .../php/runtime/Composer/EventDispatcher/Event.php | 120 ++++++ .../php/runtime/Shirabe/RustCommandStub.php | 34 ++ .../php/stubs/Composer/EventDispatcher/Event.php | 67 ---- .../Composer/EventDispatcher/EventDispatcher.php | 92 +++++ crates/shirabe-php-rpc/php/worker.php | 70 +++- crates/shirabe-php-rpc/src/lib.rs | 24 +- crates/shirabe/src/console/application.rs | 102 +++++ crates/shirabe/src/plugin/php_plugin_proxy.rs | 437 ++++++++++++++++++++- docs/dev/php-rpc.md | 53 ++- docs/dev/plugin-stub-generation.md | 19 +- scripts/plugin-stub-generator/generate-stubs | 61 ++- scripts/plugin-stub-generator/src/Generator.php | 67 +++- scripts/plugin-stub-generator/targets.list | 2 +- 14 files changed, 1188 insertions(+), 102 deletions(-) create mode 100644 crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php create mode 100644 crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php create mode 100644 crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php delete mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php create mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php 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 @@ +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 @@ +__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 @@ +, 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 @@ -__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 @@ +__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 { 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>>> { let mut commands: Vec>> = 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 = 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>>, + > = 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>, +) { + 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 { + 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::rc::Rc::new(std::cell::RefCell::new( + shirabe_external_packages::symfony::console::input::string_input::StringInput::new( + &line, + )?, + )); + let output: std::rc::Rc> = 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>), Repository(RepositoryInterfaceHandle), Package(std::rc::Rc>), + EventDispatcher( + std::rc::Rc>, + ), } /// 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, + >, + method_name: &str, + args: &[PluginValue], +) -> Result { + 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>, 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, + pub(crate) hidden: bool, +} + +impl RustCommandMetadata { + fn wire_value(&self) -> PluginValue { + let mut row: IndexMap, 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>, + initial_working_directory: Option, + disable_plugins_by_default: bool, + disable_scripts_by_default: bool, + rust_commands: Vec, + /// Clones of the plugin command handles; ownership (and release) stays with the + /// `PhpCommandProxy` instances holding the originals. + plugin_commands: Vec, + app: std::cell::RefCell>, +} + +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> = + const { std::cell::RefCell::new(Vec::new()) }; + + /// The published context, read by `PhpCommandProxy::run` at execution time. + static CONSOLE_APP_CONTEXT: std::cell::RefCell>> = + 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 { + 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>, + initial_working_directory: Option, + disable_plugins_by_default: bool, + disable_scripts_by_default: bool, + rust_commands: Vec, + plugin_commands: Vec, +) { + 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 { + if let Some(app) = self.app.borrow().as_ref() { + return Ok(app.clone()); + } + let mut config: IndexMap, 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 { 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, PluginValue>, key: &str| -> PluginValue { + row.shift_remove(key.as_bytes()) + .unwrap_or(PluginValue::Null) + }; + let as_rows = |value: PluginValue| -> Vec { + match value { + PluginValue::List(rows) => rows, + PluginValue::Array(map) => map.into_values().collect(), + _ => Vec::new(), + } + }; + + let mut items: Vec = 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 { 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>, + _output: std::rc::Rc>, + ) -> anyhow::Result { + 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>, _output: std::rc::Rc>, ) -> anyhow::Result { - // 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. - -## Proxy stubs - -`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`. +- `__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. + +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. + +## 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 $targets */ + /** @var array */ + private array $runtimeSet = []; + + /** + * @param list $targets + * @param list $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 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> + */ + 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 -- cgit v1.3.1