aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-10 23:47:33 +0900
committernsfisis <nsfisis@gmail.com>2026-08-10 23:47:33 +0900
commitb65e338d4cf06afb8237512e49a51e354277196d (patch)
tree5099c6bd950ff59acd5be15ff224b02d05563834 /crates/shirabe
parent3f80f3c725245c769f05b6d3317f086e226fae50 (diff)
downloadphp-shirabe-b65e338d4cf06afb8237512e49a51e354277196d.tar.gz
php-shirabe-b65e338d4cf06afb8237512e49a51e354277196d.tar.zst
php-shirabe-b65e338d4cf06afb8237512e49a51e354277196d.zip
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.
Diffstat (limited to 'crates/shirabe')
-rw-r--r--crates/shirabe/src/console/application.rs239
-rw-r--r--crates/shirabe/src/plugin/php_plugin_proxy.rs129
-rw-r--r--crates/shirabe/tests/plugin/e2e_script_command_test.rs157
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json23
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php34
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php23
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php9
-rw-r--r--crates/shirabe/tests/plugin/main.rs1
8 files changed, 536 insertions, 79 deletions
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<PartialComposerHandle>,
pub(crate) io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
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<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,
+ 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<shirabe_php_rpc::PhpObjHandle>,
+ ) -> 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<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,
+ handles,
+ );
+ register_worker_reverse_application(self.me.clone());
+ Ok(())
+ }
+
/// Get the working directory at startup time
pub fn get_initial_working_directory(&self) -> Option<String> {
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<std::cell::RefCell<dyn SymfonyCommand>> =
- 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!("<warning>The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.</warning>", 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!("<warning>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.</warning>", 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(
diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs
index e4384f1c..4edc76ce 100644
--- a/crates/shirabe/src/plugin/php_plugin_proxy.rs
+++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs
@@ -30,7 +30,7 @@ use crate::repository::{
use indexmap::IndexMap;
use shirabe_php_rpc::{
PhpObjHandle, PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle,
- call_function_with_dispatcher, call_php_method, release_php_handle,
+ call_function_with_dispatcher, call_php_method, new_object, release_php_handle,
};
use shirabe_php_shim::PhpMixed;
use shirabe_symfony_console::command::Command;
@@ -2362,6 +2362,58 @@ pub fn io_handle_value(
Ok(rust_handle_value(register_io_entity(io), class))
}
+/// Runs a boolean class query (`class_exists`, `is_subclass_of`, ...) in the worker with the
+/// script autoloader active, so a class named by `composer.json` resolves through the Rust-side
+/// [`ClassLoader`]s.
+pub(crate) fn php_class_query(function: &str, args: Vec<PluginValue>) -> anyhow::Result<bool> {
+ crate::event_dispatcher::EventDispatcher::ensure_script_autoloader()?;
+ let value = unwrap_php_result(call_function_with_dispatcher(
+ function,
+ args,
+ Some(&mut PluginRpcDispatcher::default()),
+ ))?;
+ match value {
+ PluginValue::Bool(value) => Ok(value),
+ other => Err(anyhow::anyhow!(
+ "PHP class query `{function}` did not return a bool: {other:?}"
+ )),
+ }
+}
+
+/// Instantiates `new $class(...$ctor_args)` in the worker, with the script autoloader active.
+pub(crate) fn new_php_object(
+ class: &str,
+ ctor_args: Vec<PluginValue>,
+) -> anyhow::Result<PhpObjHandle> {
+ crate::event_dispatcher::EventDispatcher::ensure_script_autoloader()?;
+ let value = unwrap_php_result(new_object(
+ class,
+ ctor_args,
+ Some(&mut PluginRpcDispatcher::default()),
+ ))?;
+ match value {
+ PluginValue::PhpHandle(handle) => Ok(handle),
+ other => Err(shirabe_php_shim::RuntimeException::new(format!(
+ "`new {class}` returned an unsupported shape over RPC: {other:?}"
+ ))
+ .into()),
+ }
+}
+
+/// Calls `$obj->$method(...$args)` on a worker-side entity.
+pub(crate) fn call_php_entity_method(
+ handle: &PhpObjHandle,
+ method: &str,
+ args: Vec<PluginValue>,
+) -> anyhow::Result<PluginValue> {
+ unwrap_php_result(call_php_method(
+ handle.phandle,
+ method,
+ args,
+ Some(&mut PluginRpcDispatcher::default()),
+ ))
+}
+
/// `is_a($obj, $class)` evaluated in the worker: the child's own class table answers, so
/// parent classes are covered (a `PhpObjHandle`'s `implements` lists interfaces only).
pub(crate) fn php_is_a(handle: &PhpObjHandle, class: &str) -> anyhow::Result<bool> {
@@ -2742,7 +2794,7 @@ 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
+/// `\Shirabe\RustCommandStub` so a worker-hosted command can `find()` and invoke built-in
/// commands (their execution crosses back into this process).
#[derive(Debug)]
pub(crate) struct RustCommandMetadata {
@@ -2776,25 +2828,25 @@ impl RustCommandMetadata {
/// 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.
+/// `Application::register_worker_console_commands` as worker-hosted commands are registered,
+/// booted in the worker the first time one of them actually runs.
#[derive(Debug)]
pub(crate) struct PhpConsoleApplicationContext {
- composer: ComposerHandle,
+ composer: Option<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
+ /// Clones of the worker-hosted command handles; ownership (and release) stays with the
/// `PhpCommandProxy` instances holding the originals.
- plugin_commands: Vec<PhpObjHandle>,
+ plugin_commands: std::cell::RefCell<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.
+ /// Handles of the `PhpCommandProxy` instances built since the last drain; drained into the
+ /// console application context by `Application::register_worker_console_commands`.
static PENDING_COMMAND_HANDLES: std::cell::RefCell<Vec<PhpObjHandle>> =
const { std::cell::RefCell::new(Vec::new()) };
@@ -2813,7 +2865,7 @@ pub(crate) fn take_pending_plugin_command_handles() -> Vec<PhpObjHandle> {
}
pub(crate) fn publish_console_application_context(
- composer: &ComposerHandle,
+ composer: Option<&ComposerHandle>,
io: &std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
initial_working_directory: Option<String>,
disable_plugins_by_default: bool,
@@ -2822,18 +2874,36 @@ pub(crate) fn publish_console_application_context(
plugin_commands: Vec<PhpObjHandle>,
) {
let context = std::rc::Rc::new(PhpConsoleApplicationContext {
- composer: composer.clone(),
+ composer: composer.cloned(),
io: io.clone(),
initial_working_directory,
disable_plugins_by_default,
disable_scripts_by_default,
rust_commands,
- plugin_commands,
+ plugin_commands: std::cell::RefCell::new(plugin_commands),
app: std::cell::RefCell::new(None),
});
CONSOLE_APP_CONTEXT.with(|slot| *slot.borrow_mut() = Some(context));
}
+/// Adds command entities to the published handoff, for commands registered after it was
+/// published. A worker-side application that already booted gets them right away, so the two
+/// sides keep the same command set.
+pub(crate) fn extend_console_application_commands(
+ handles: Vec<PhpObjHandle>,
+) -> anyhow::Result<()> {
+ let context = CONSOLE_APP_CONTEXT
+ .with(|slot| slot.borrow().clone())
+ .expect("only an application that published the handoff extends it");
+ for handle in handles {
+ if let Some(app) = context.app.borrow().as_ref() {
+ call_php_entity_method(app, "add", vec![PluginValue::PhpHandle(handle.clone())])?;
+ }
+ context.plugin_commands.borrow_mut().push(handle);
+ }
+ Ok(())
+}
+
impl PhpConsoleApplicationContext {
/// Boots the worker-side application on first use and returns its handle.
fn booted_app(&self) -> anyhow::Result<PhpObjHandle> {
@@ -2841,7 +2911,13 @@ impl PhpConsoleApplicationContext {
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"composer".to_vec(),
+ match &self.composer {
+ Some(composer) => composer_handle_value(composer),
+ None => PluginValue::Null,
+ },
+ );
config.insert(b"io".to_vec(), io_handle_value(&self.io)?);
config.insert(
b"initialWorkingDirectory".to_vec(),
@@ -2871,6 +2947,7 @@ impl PhpConsoleApplicationContext {
b"pluginCommands".to_vec(),
PluginValue::List(
self.plugin_commands
+ .borrow()
.iter()
.cloned()
.map(PluginValue::PhpHandle)
@@ -2919,6 +2996,28 @@ pub struct PhpCommandProxy {
impl PhpCommandProxy {
pub(crate) fn new(handle: PhpObjHandle) -> anyhow::Result<Self> {
+ 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::build(handle, proxy_command)
+ }
+
+ /// A command class named by a `composer.json` script only has to extend Symfony's `Command`,
+ /// so `isProxyCommand()` is asked for only when it also extends Composer's `BaseCommand`.
+ pub(crate) fn new_script_command(handle: PhpObjHandle) -> anyhow::Result<Self> {
+ let proxy_command = if php_is_a(&handle, "Composer\\Command\\BaseCommand")? {
+ match Self::call_metadata_getter(&handle, "isProxyCommand")? {
+ PluginValue::Bool(proxy_command) => proxy_command,
+ other => return Err(Self::unsupported_shape(&handle, "isProxyCommand", &other)),
+ }
+ } else {
+ false
+ };
+ Self::build(handle, proxy_command)
+ }
+
+ fn build(handle: PhpObjHandle, proxy_command: bool) -> anyhow::Result<Self> {
let data = crate::command::BaseCommandData::new(None);
let name = Self::call_metadata_getter(&handle, "getName")?;
match name {
@@ -2958,10 +3057,6 @@ 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 {
diff --git a/crates/shirabe/tests/plugin/e2e_script_command_test.rs b/crates/shirabe/tests/plugin/e2e_script_command_test.rs
new file mode 100644
index 00000000..019c830d
--- /dev/null
+++ b/crates/shirabe/tests/plugin/e2e_script_command_test.rs
@@ -0,0 +1,157 @@
+//! Script-provided command E2E compatibility check: a `composer.json` script naming a
+//! `Symfony\Component\Console\Command\Command` subclass is imported as an application command,
+//! and `list`, `help`, executions and the mismatched-name warning are compared between upstream
+//! Composer and Shirabe.
+//!
+//! The whole fixture is Shirabe-authored (`fixtures/e2e-script-command/`), so nothing has to be
+//! fetched; the test skips only while the PHP runtime or the Composer checkout is missing.
+
+use crate::e2e_extension_installer_test::{copy_dir, upstream_composer_bin};
+use crate::plugin_installer_test::{lock_php_worker, php_runtime_available};
+use std::path::{Path, PathBuf};
+use tempfile::TempDir;
+
+fn fixture_dir() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e-script-command")
+}
+
+struct CommandRun {
+ exit_code: i32,
+ stdout: String,
+ stderr: String,
+}
+
+/// One composer-CLI invocation inside the prepared project.
+fn run_command(work: &Path, program: &str, prefix_args: &[&str], args: &[&str]) -> CommandRun {
+ let output = std::process::Command::new(program)
+ .args(prefix_args)
+ .args(args)
+ .current_dir(work)
+ .env("COMPOSER_HOME", work.join("home"))
+ .env("COMPOSER_CACHE_DIR", work.join("cache"))
+ .env("COMPOSER_NO_INTERACTION", "1")
+ // Rendering width must not depend on the invoking terminal.
+ .env("COLUMNS", "120")
+ .env("LINES", "30")
+ .output()
+ .unwrap();
+ CommandRun {
+ exit_code: output.status.code().unwrap_or(-1),
+ stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
+ stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
+ }
+}
+
+fn lines_starting_with<'a>(text: &'a str, prefix: &str) -> Vec<&'a str> {
+ text.lines()
+ .map(str::trim_end)
+ .filter(|line| line.trim_start().starts_with(prefix))
+ .collect()
+}
+
+#[test]
+fn test_script_command_class_import_matches_upstream_composer() {
+ if !php_runtime_available() {
+ return;
+ }
+ let Some(composer_bin) = upstream_composer_bin() else {
+ return;
+ };
+ let _worker = lock_php_worker();
+
+ let composer_bin = composer_bin.to_str().unwrap().to_string();
+ let implementations: [(&str, Vec<&str>); 2] = [
+ ("php", vec![composer_bin.as_str()]),
+ (env!("CARGO_BIN_EXE_shirabe"), vec![]),
+ ];
+
+ let mut results: Vec<[CommandRun; 4]> = Vec::new();
+ for (program, prefix) in &implementations {
+ let work = TempDir::new().unwrap();
+ copy_dir(&fixture_dir(), work.path());
+ let install = run_command(work.path(), program, prefix, &["install"]);
+ assert_eq!(0, install.exit_code, "{program}: install must succeed");
+
+ let greet = run_command(work.path(), program, prefix, &["greet", "World", "--shout"]);
+ let renamed = run_command(work.path(), program, prefix, &["renamed"]);
+ let help = run_command(work.path(), program, prefix, &["help", "greet"]);
+ let list = run_command(work.path(), program, prefix, &["list"]);
+ results.push([greet, renamed, help, list]);
+ }
+
+ let [upstream, shirabe] = <[_; 2]>::try_from(results).ok().unwrap();
+ let [u_greet, u_renamed, u_help, u_list] = upstream;
+ let [s_greet, s_renamed, s_help, s_list] = shirabe;
+
+ // The imported command runs with its own definition bound: the argument, the shorthand
+ // option, the name the constructor took from composer.json and the hosting application.
+ assert_eq!(0, u_greet.exit_code, "upstream greet must succeed");
+ assert_eq!(u_greet.exit_code, s_greet.exit_code);
+ assert_eq!(
+ lines_starting_with(&u_greet.stdout, "greet:"),
+ lines_starting_with(&s_greet.stdout, "greet:")
+ );
+ assert_eq!(
+ vec![
+ "greet: HELLO WORLD",
+ "greet: name=greet",
+ "greet: app=Composer\\Console\\Application",
+ ],
+ lines_starting_with(&s_greet.stdout, "greet:")
+ );
+
+ // A class whose configure() sets a different name is renamed to the script name, and an
+ // empty description is filled in from scripts-descriptions.
+ assert_eq!(0, u_renamed.exit_code, "upstream renamed must succeed");
+ assert_eq!(u_renamed.exit_code, s_renamed.exit_code);
+ assert_eq!(
+ lines_starting_with(&u_renamed.stdout, "renamed:"),
+ lines_starting_with(&s_renamed.stdout, "renamed:")
+ );
+ assert_eq!(
+ vec![
+ "renamed: name=renamed",
+ "renamed: description=Description taken from composer.json",
+ ],
+ lines_starting_with(&s_renamed.stdout, "renamed:")
+ );
+ let mismatch_warning =
+ "The script named renamed in composer.json has a mismatched name in its class definition.";
+ assert!(
+ u_renamed.stderr.contains(mismatch_warning),
+ "upstream must warn about the mismatched name: {}",
+ u_renamed.stderr
+ );
+ assert!(
+ s_renamed.stderr.contains(mismatch_warning),
+ "shirabe must warn about the mismatched name: {}",
+ s_renamed.stderr
+ );
+
+ // A class extending SingleCommandApplication is still imported, with a warning.
+ let single_warning = "The script named single extends SingleCommandApplication which is not compatible with Composer 2.9+";
+ assert!(
+ u_list.stderr.contains(single_warning),
+ "upstream must warn about SingleCommandApplication: {}",
+ u_list.stderr
+ );
+ assert!(
+ s_list.stderr.contains(single_warning),
+ "shirabe must warn about SingleCommandApplication: {}",
+ s_list.stderr
+ );
+
+ assert_eq!(0, u_help.exit_code, "upstream help greet must succeed");
+ assert_eq!(u_help.exit_code, s_help.exit_code);
+ assert_eq!(u_help.stdout, s_help.stdout, "help greet output differs");
+
+ // The imported commands are listed with the descriptions the class and composer.json give
+ // them, next to the plain shell script that stays a ScriptAliasCommand.
+ assert_eq!(0, u_list.exit_code);
+ assert_eq!(u_list.exit_code, s_list.exit_code);
+ assert_eq!(u_list.stdout, s_list.stdout, "list output differs");
+ assert_eq!(
+ vec![" greet Greets someone from a script-provided command."],
+ lines_starting_with(&s_list.stdout, "greet")
+ );
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json
new file mode 100644
index 00000000..2eeff25b
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json
@@ -0,0 +1,23 @@
+{
+ "name": "shirabe/e2e-script-command",
+ "description": "E2E fixture project: composer.json scripts naming Command classes, run against upstream Composer and Shirabe.",
+ "autoload": {
+ "psr-4": {
+ "ShirabeTest\\ScriptCommand\\": "src/"
+ }
+ },
+ "scripts": {
+ "greet": "ShirabeTest\\ScriptCommand\\GreetCommand",
+ "renamed": "ShirabeTest\\ScriptCommand\\MismatchedNameCommand",
+ "single": "ShirabeTest\\ScriptCommand\\SingleAppCommand",
+ "plain": "echo plain-script"
+ },
+ "scripts-descriptions": {
+ "renamed": "Description taken from composer.json"
+ },
+ "repositories": [
+ {
+ "packagist.org": false
+ }
+ ]
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php
new file mode 100644
index 00000000..e797486b
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace ShirabeTest\ScriptCommand;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class GreetCommand extends Command
+{
+ protected function configure(): void
+ {
+ $this
+ ->setDescription('Greets someone from a script-provided command.')
+ ->setHelp('The <info>greet</info> command exercises a Command class named by a composer.json script.')
+ ->addArgument('who', InputArgument::REQUIRED, 'Who to greet')
+ ->addOption('shout', 's', InputOption::VALUE_NONE, 'Uppercase the greeting');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $message = 'Hello ' . $input->getArgument('who');
+ if ($input->getOption('shout')) {
+ $message = strtoupper($message);
+ }
+ $output->writeln('greet: ' . $message);
+ $output->writeln('greet: name=' . $this->getName());
+ $output->writeln('greet: app=' . get_class($this->getApplication()));
+
+ return 0;
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php
new file mode 100644
index 00000000..f3f6b05f
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace ShirabeTest\ScriptCommand;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class MismatchedNameCommand extends Command
+{
+ protected function configure(): void
+ {
+ $this->setName('not-the-script-name');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $output->writeln('renamed: name=' . $this->getName());
+ $output->writeln('renamed: description=' . $this->getDescription());
+
+ return 0;
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php
new file mode 100644
index 00000000..89e17fe9
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace ShirabeTest\ScriptCommand;
+
+use Symfony\Component\Console\SingleCommandApplication;
+
+class SingleAppCommand extends SingleCommandApplication
+{
+}
diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs
index f1237731..8c0b394d 100644
--- a/crates/shirabe/tests/plugin/main.rs
+++ b/crates/shirabe/tests/plugin/main.rs
@@ -10,6 +10,7 @@ mod e2e_installer_test;
mod e2e_installers_test;
mod e2e_normalize_test;
mod e2e_package_event_test;
+mod e2e_script_command_test;
mod plugin_installer_test;
mod subscriber_test;
mod value_round_trip_test;