From b65e338d4cf06afb8237512e49a51e354277196d Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 10 Aug 2026 23:47:33 +0900 Subject: feat(console): import scripts Command classes as application commands `Application::do_run` registers a `composer.json` script whose value names a `Symfony\Component\Console\Command\Command` subclass as a live command. The class checks and `new $dummy($script)` need a real PHP runtime, so they run in the worker: the command object lives there and this side keeps a metadata mirror for `list`/`help`, forwarding a run to the worker-side console application it is added to. The name and description fixups are applied to the worker-side object, so both sides carry the same values. Loading the Composer PHP runtime into the worker is gated on the Rust-side `ClassLoader`s resolving the class to a file, keeping that load out of every run whose scripts are plain shell commands. The worker-side console application handoff now accepts commands registered after it was published, since the scripts scan runs after plugin commands are collected. Whether it was published is tracked per application: the handoff is process-wide, so a second application must replace it rather than extend it. `shirabe_php_shim::is_subclass_of` has no callers left. --- crates/shirabe/src/console/application.rs | 239 ++++++++++++++++++++++-------- 1 file changed, 177 insertions(+), 62 deletions(-) (limited to 'crates/shirabe/src/console/application.rs') diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 897408c0..c0cd072b 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -38,6 +38,7 @@ use crate::composer; use crate::composer::PartialComposerHandle; use crate::console::GithubActionError; use crate::downloader::TransportException; +use crate::event_dispatcher::EventDispatcher; use crate::event_dispatcher::ScriptExecutionException; use crate::exception::NoSslException; use crate::factory::Factory; @@ -60,9 +61,9 @@ use shirabe_php_shim::{ bin2hex, chdir, date_default_timezone_get, date_default_timezone_set, defined, dirname, disk_free_space, extension_loaded, file_exists, file_get_contents, file_put_contents, function_exists, getcwd, getmypid, glob, ini_set, is_array, is_dir, is_file, is_string, - is_subclass_of, json_decode, memory_get_peak_usage, memory_get_usage, microtime, php_regex, - php_uname, posix_getuid, random_bytes, realpath, restore_error_handler, round, str_contains, - str_replace, strpos, strtoupper, sys_get_temp_dir, time, unlink, + json_decode, memory_get_peak_usage, memory_get_usage, microtime, php_regex, php_uname, + posix_getuid, random_bytes, realpath, restore_error_handler, round, str_contains, str_replace, + strpos, strtoupper, sys_get_temp_dir, time, unlink, }; use shirabe_seld_json_lint::ParsingException; use shirabe_symfony_console::application::Application as BaseApplication; @@ -122,6 +123,10 @@ pub struct Application { pub(crate) composer: Option, pub(crate) io: std::rc::Rc>, has_plugin_commands: bool, + /// Whether this application published the worker-side console application handoff. The + /// handoff is process-wide, so a second application in the same process must replace it + /// rather than extend the one its predecessor left behind. + published_worker_console_context: bool, disable_plugins_by_default: bool, disable_scripts_by_default: bool, /// Store the initial working directory at startup time @@ -188,6 +193,7 @@ impl Application { composer: None, io, has_plugin_commands: false, + published_worker_console_context: false, disable_plugins_by_default: false, disable_scripts_by_default: false, initial_working_directory, @@ -631,45 +637,70 @@ 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, + self.register_worker_console_commands( + Some(&composer), crate::plugin::take_pending_plugin_command_handles(), - ); - register_worker_reverse_application(self.me.clone()); + )?; } } Ok(commands) } + /// Makes worker-hosted commands runnable through the worker-side console application: they + /// join the handoff it boots from when one of them actually runs. This application's first + /// call publishes that handoff, carrying the shared object graph plus metadata mirrors of + /// the commands registered so far — all Rust-implemented at that point, since a worker-hosted + /// command is only registered after being handed to this method. + /// + /// TODO(plugin): a Rust-implemented command registered *after* this first call is missing + /// from that mirror, so the worker cannot `find()` it. `Application::do_run` hits this when + /// one `scripts` entry names a Command class and a later entry falls back to + /// `ScriptAliasCommand`: the alias command is registered after the class command published + /// the handoff. Fixing it needs a metadata half of `extend_console_application_commands` + /// that also builds a `\Shirabe\RustCommandStub` in an already-booted worker application. + fn register_worker_console_commands( + &mut self, + composer: Option<&crate::composer::ComposerHandle>, + handles: Vec, + ) -> anyhow::Result<()> { + if self.published_worker_console_context { + return crate::plugin::extend_console_application_commands(handles); + } + self.published_worker_console_context = true; + + 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, + handles, + ); + register_worker_reverse_application(self.me.clone()); + Ok(()) + } + /// Get the working directory at startup time pub fn get_initial_working_directory(&self) -> Option { self.initial_working_directory.clone() @@ -2389,8 +2420,8 @@ impl ApplicationHandle { let composer_opt = application.borrow_mut().get_composer(false, None, None)?; - if let Some(composer) = composer_opt { - let composer = crate::composer::composer_full(&composer); + if let Some(ref composer_handle) = composer_opt { + let composer = crate::composer::composer_full(composer_handle); let root_package = composer.get_package(); let generator = composer.get_autoload_generator().clone(); let generator = generator.borrow(); @@ -2421,34 +2452,118 @@ impl ApplicationHandle { // if the command is not an array of commands, and points to a valid SymfonyCommand subclass, import its details directly let dummy_str = dummy.as_string().unwrap_or("").to_string(); + // The class lives in the PHP worker, which cannot even declare + // a subclass of Symfony's Command before the Composer PHP + // runtime is loaded there. That load is skipped for a class the + // Rust-side ClassLoaders cannot resolve to a file, which is + // every plain shell-command script. + // + // TODO(php-runtime): the file lookup is narrower than PHP's + // `class_exists`, which is also true for a class already + // declared in the process. A script naming one of those (say + // `Composer\Command\AboutCommand`) is imported as a command by + // PHP but falls through to ScriptAliasCommand here. + let is_command_class = is_string(dummy) + && crate::plugin::find_file_in_registered_loaders(&dummy_str) + .is_some() + && { + EventDispatcher::ensure_composer_php_runtime()?; + crate::plugin::php_class_query( + "class_exists", + vec![shirabe_php_rpc::PluginValue::string( + dummy_str.clone(), + )], + )? && crate::plugin::php_class_query( + "is_subclass_of", + vec![ + shirabe_php_rpc::PluginValue::string( + dummy_str.clone(), + ), + shirabe_php_rpc::PluginValue::string( + "Symfony\\Component\\Console\\Command\\Command", + ), + shirabe_php_rpc::PluginValue::Bool(true), + ], + )? + }; let cmd: std::rc::Rc> = - if is_string(dummy) - && shirabe_php_shim::class_exists(&dummy_str) - && is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\Command\\Command", - true, - ) - { - if is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\SingleCommandApplication", - true, - ) { + if is_command_class { + if crate::plugin::php_class_query( + "is_subclass_of", + vec![ + shirabe_php_rpc::PluginValue::string( + dummy_str.clone(), + ), + shirabe_php_rpc::PluginValue::string( + "Symfony\\Component\\Console\\SingleCommandApplication", + ), + shirabe_php_rpc::PluginValue::Bool(true), + ], + )? { io.write_error(&format!("The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.", script)); } - // TODO(plugin): `new $dummy($script)` instantiates the - // user's PHP command class in-process and registers the - // live object on this Application. The worker-side - // console application and PhpCommandProxy exist now, - // but this arm is not wired to them: the class checks - // above use the shim class_exists, which never - // recognizes user classes, so the arm stays - // unreachable until the checks and the instantiation - // go through the worker. - todo!( - "plugin: import a user Command class as a live application command" - ); + + let handle = crate::plugin::new_php_object( + &dummy_str, + vec![shirabe_php_rpc::PluginValue::string( + script.clone(), + )], + )?; + + // makes sure the command is find()'able by the name defined in composer.json, and the name isn't overridden in its configure() + let cmd_name = crate::plugin::call_php_entity_method( + &handle, + "getName", + vec![], + )?; + if let shirabe_php_rpc::PluginValue::String(ref bytes) = + cmd_name + && !bytes.is_empty() + && bytes.as_slice() != script.as_bytes() + { + io.write_error(&format!("The script named {} in composer.json has a mismatched name in its class definition. For consistency, either use the same name, or do not define one inside the class.", script)); + // override it with the defined script name + crate::plugin::call_php_entity_method( + &handle, + "setName", + vec![shirabe_php_rpc::PluginValue::string( + script.clone(), + )], + )?; + } + + let cmd_description = + crate::plugin::call_php_entity_method( + &handle, + "getDescription", + vec![], + )?; + if matches!( + cmd_description, + shirabe_php_rpc::PluginValue::String(ref bytes) + if bytes.is_empty() + ) { + crate::plugin::call_php_entity_method( + &handle, + "setDescription", + vec![shirabe_php_rpc::PluginValue::string( + description, + )], + )?; + } + + let cmd = + crate::plugin::PhpCommandProxy::new_script_command( + handle, + )?; + application.borrow_mut().register_worker_console_commands( + composer_opt + .as_ref() + .and_then(|c| c.as_full()) + .as_ref(), + crate::plugin::take_pending_plugin_command_handles(), + )?; + std::rc::Rc::new(std::cell::RefCell::new(cmd)) } else { // fallback to usual aliasing behavior std::rc::Rc::new(std::cell::RefCell::new( -- cgit v1.3.1-4-g156e