From 6051927c8fa32cfffa102d2a170c5a6cf747a1b9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 9 Aug 2026 11:19:03 +0900 Subject: refactor(symfony-console): extract symfony/console into the shirabe-symfony-console crate Move `Symfony\Component\Console` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_console::application::Application` instead of `shirabe_external_packages::symfony::console::application::Application`. The `delegate_to_inner!` and `delegate_command_trait_impls_to_inner!` macros move with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../shirabe-symfony-console/src/command/command.rs | 897 +++++++++++++++++++++ .../src/command/complete_command.rs | 415 ++++++++++ .../src/command/dump_completion_command.rs | 295 +++++++ .../src/command/help_command.rs | 171 ++++ .../src/command/list_command.rs | 172 ++++ .../src/command/signalable_command_interface.rs | 10 + 6 files changed, 1960 insertions(+) create mode 100644 crates/shirabe-symfony-console/src/command/command.rs create mode 100644 crates/shirabe-symfony-console/src/command/complete_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/dump_completion_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/help_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/list_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/signalable_command_interface.rs (limited to 'crates/shirabe-symfony-console/src/command') diff --git a/crates/shirabe-symfony-console/src/command/command.rs b/crates/shirabe-symfony-console/src/command/command.rs new file mode 100644 index 00000000..f0d9dfe0 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/command.rs @@ -0,0 +1,897 @@ +//! ref: composer/vendor/symfony/console/Command/Command.php + +use crate::application::Application; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::CompletionSuggestions; +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::helper::helper_set::HelperSet; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::{self, OutputInterface}; +use indexmap::IndexMap; +use shirabe_php_shim::{PhpMixed, php_regex}; +use std::cell::{Cell, Ref}; + +/// The base-class state of the PHP `Command` class. +/// +/// The PHP `Command` class is split into the polymorphic [`Command`] trait (the +/// methods callers invoke on a command of unknown concrete type) and this struct, +/// which holds the base-class fields and provides their canonical behavior via +/// `impl Command for CommandData`. Subclasses embed a `CommandData` (directly, or +/// transitively through `BaseCommandData`) and forward the state methods to it. +/// +/// The mutable fields use interior mutability (`Cell`/`RefCell`) so that the `Command` +/// trait methods take `&self`, mirroring PHP's reference semantics: calling a method on a +/// command does not lock the object, so a command can be re-entered (e.g. the help command +/// describing itself) without the borrow conflicts a `&mut self` design would cause. +pub struct CommandData { + application: std::cell::RefCell>>>, + name: std::cell::RefCell>, + process_title: std::cell::RefCell>, + aliases: std::cell::RefCell>, + definition: std::cell::RefCell>, + hidden: Cell, + help: std::cell::RefCell, + description: std::cell::RefCell, + full_definition: std::cell::RefCell>, + ignore_validation_errors: Cell, + // A callable(InputInterface, OutputInterface) -> i64. + code: std::cell::RefCell< + Option PhpMixed>>, + >, + synopsis: std::cell::RefCell>, + usages: std::cell::RefCell>, + helper_set: std::cell::RefCell>>>, +} + +impl CommandData { + // see https://tldp.org/LDP/abs/html/exitcodes.html + pub const SUCCESS: i64 = 0; + pub const FAILURE: i64 = 1; + pub const INVALID: i64 = 2; + + /// The default command name. + // NOTE: PHP `protected static $defaultName`; static late-binding property. + pub const DEFAULT_NAME: Option<&'static str> = None; + + /// The default command description. + // NOTE: PHP `protected static $defaultDescription`; static late-binding property. + pub const DEFAULT_DESCRIPTION: Option<&'static str> = None; + + pub fn get_default_name() -> Option { + // TODO(phase-c): PHP uses ReflectionClass to read the #[AsCommand] attribute + // and ReflectionProperty to check that `$defaultName` is declared on the late-static + // class itself (not inherited). Reflection-based late static binding has no direct + // Rust equivalent; human review needed for the porting strategy. + todo!() + } + + pub fn get_default_description() -> Option { + // TODO(phase-c): same Reflection/late-static-binding concern as get_default_name(). + todo!() + } + + /// Builds the base-class state. `name` is the name of the command; passing None + /// means it must be set in the subclass `configure()`. + /// + /// Unlike PHP's `__construct`, this does not call `configure()` — the concrete + /// command's `new()` calls `configure()` after embedding the data, mirroring the + /// virtual dispatch of `$this->configure()` from the parent constructor. + pub fn new(name: Option) -> Self { + let this = CommandData { + application: std::cell::RefCell::new(None), + name: std::cell::RefCell::new(None), + process_title: std::cell::RefCell::new(None), + aliases: std::cell::RefCell::new(Vec::new()), + definition: std::cell::RefCell::new(Some( + InputDefinition::new(Vec::new()).expect("an empty InputDefinition cannot fail"), + )), + hidden: Cell::new(false), + help: std::cell::RefCell::new(String::new()), + description: std::cell::RefCell::new(String::new()), + full_definition: std::cell::RefCell::new(None), + ignore_validation_errors: Cell::new(false), + code: std::cell::RefCell::new(None), + synopsis: std::cell::RefCell::new(IndexMap::new()), + usages: std::cell::RefCell::new(Vec::new()), + helper_set: std::cell::RefCell::new(None), + }; + + // PHP's __construct also derives the name from getDefaultName() when null and + // sets the default description; both rely on Reflection late-static-binding + // (get_default_name/get_default_description are todo!()), and concrete commands + // always set their name in configure(), so only an explicit name is honored here. + if let Some(name) = name { + *this.name.borrow_mut() = Some(name); + } + + this + } + + /// Applies a `$defaultName`-style name (PHP `Command::__construct` when `$name` is null and a + /// `static $defaultName` exists). The string is `|`-separated; a leading empty segment marks the + /// command hidden, the next segment is the name, and the rest are aliases. + pub fn apply_default_name(&self, default_name: &str) -> anyhow::Result<()> { + let mut aliases: Vec = default_name.split('|').map(|s| s.to_string()).collect(); + let mut name = aliases.remove(0); + if name.is_empty() { + self.set_hidden(true); + name = if aliases.is_empty() { + String::new() + } else { + aliases.remove(0) + }; + } + self.set_name(&name)?; + self.set_aliases(aliases)?; + Ok(()) + } + + /// Validates a command name. + /// + /// It must be non-empty and parts can optionally be separated by ":". + /// + /// Throws InvalidArgumentException when the name is invalid. + fn validate_name(&self, name: &str) -> anyhow::Result> { + let mut matches: Vec> = Vec::new(); + if !shirabe_php_shim::preg_match(php_regex!(r"/^[^\:]++(\:[^\:]++)*$/"), name, &mut matches) + { + return Ok(Err(InvalidArgumentException::new(format!( + "Command name \"{}\" is invalid.", + name + )))); + } + + Ok(Ok(())) + } + + /// Sets an array of argument and option instances (the Symfony-typed entry point; + /// `BaseCommand::set_definition` adapts the Composer-typed arguments to this). + pub fn set_definition(&self, definition: SetDefinitionArg) -> &Self { + match definition { + SetDefinitionArg::Definition(definition) => { + *self.definition.borrow_mut() = Some(definition); + } + SetDefinitionArg::Array(definition) => { + let _ = self + .definition + .borrow_mut() + .as_mut() + .unwrap() + .set_definition(definition); + } + } + + *self.full_definition.borrow_mut() = None; + + self + } + + /// Adds an argument (Symfony-typed entry point). + /// + /// Throws InvalidArgumentException when argument mode is not valid. + pub fn add_argument( + &self, + name: &str, + mode: Option, + description: &str, + default: PhpMixed, + ) -> anyhow::Result<&Self> { + self.definition + .borrow_mut() + .as_mut() + .unwrap() + .add_argument(InputArgument::new( + name.to_string(), + mode, + description.to_string(), + default.clone(), + )?)?; + if let Some(full_definition) = self.full_definition.borrow_mut().as_mut() { + full_definition.add_argument(InputArgument::new( + name.to_string(), + mode, + description.to_string(), + default, + )?)?; + } + + Ok(self) + } + + /// Adds an option (Symfony-typed entry point). + /// + /// Throws InvalidArgumentException if option mode is invalid or incompatible. + pub fn add_option( + &self, + name: &str, + shortcut: PhpMixed, + mode: Option, + description: &str, + default: PhpMixed, + ) -> anyhow::Result<&Self> { + self.definition + .borrow_mut() + .as_mut() + .unwrap() + .add_option(InputOption::new( + name, + shortcut.clone(), + mode, + description.to_string(), + default.clone(), + )?)?; + if let Some(full_definition) = self.full_definition.borrow_mut().as_mut() { + full_definition.add_option(InputOption::new( + name, + shortcut, + mode, + description.to_string(), + default, + )?)?; + } + + Ok(self) + } +} + +/// The argument of `CommandData::set_definition()`, which accepts either an array of +/// argument/option instances or an InputDefinition. +#[derive(Debug)] +pub enum SetDefinitionArg { + Array(Vec), + Definition(InputDefinition), +} + +/// Forwards a single trait method to an embedded field that already implements the +/// method (the "inner" command-state holder). +/// +/// Each `Command`/`BaseCommand` implementer spells out the methods it delegates, one +/// `delegate_to_inner!` per method, alongside the few methods it overrides by hand. +/// The first argument names the field to forward to; the second is the method's +/// signature. Every method takes `&self` (the command state is interior-mutable); +/// fluent setters returning `&Self` (optionally wrapped in `anyhow::Result`) are handled +/// specially so the returned reference is re-rooted at the outer `self` rather than the +/// inner field. +#[macro_export] +macro_rules! delegate_to_inner { + // fluent fallible: -> anyhow::Result<&Self> + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?) -> anyhow::Result<&Self>) => { + fn $name(&self $(, $arg: $ty)*) -> anyhow::Result<&Self> { + self.$field.$name($($arg),*)?; + Ok(self) + } + }; + // fluent infallible: -> &Self + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?) -> &Self) => { + fn $name(&self $(, $arg: $ty)*) -> &Self { + self.$field.$name($($arg),*); + self + } + }; + // &self with a return type + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?) -> $ret:ty) => { + fn $name(&self $(, $arg: $ty)*) -> $ret { + self.$field.$name($($arg),*) + } + }; + // &self without a return type + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?)) => { + fn $name(&self $(, $arg: $ty)*) { + self.$field.$name($($arg),*) + } + }; +} + +/// Forwards every `Command` state method (the setters/getters whose canonical impl lives on +/// `CommandData` and which no subclass overrides) to an embedded field. Each command invokes +/// this once inside its `impl Command` block and spells out by hand only the behavior hooks it +/// overrides (`configure`/`execute`/`initialize`/...). The single argument names the field to +/// forward to (`inner` for Symfony commands, `base_command_data` for Composer commands). +#[macro_export] +macro_rules! delegate_command_trait_impls_to_inner { + ($field:ident) => { + $crate::delegate_to_inner!($field, fn is_enabled(&self) -> bool); + $crate::delegate_to_inner!($field, fn set_application(&self, application: Option>>)); + $crate::delegate_to_inner!($field, fn get_application(&self) -> Option>>); + $crate::delegate_to_inner!($field, fn set_helper_set(&self, helper_set: std::rc::Rc>)); + $crate::delegate_to_inner!($field, fn get_helper_set(&self) -> Option>>); + $crate::delegate_to_inner!($field, fn merge_application_definition(&self, merge_args: bool)); + $crate::delegate_to_inner!($field, fn get_definition(&self) -> std::cell::Ref<'_, $crate::input::input_definition::InputDefinition>); + $crate::delegate_to_inner!($field, fn get_native_definition(&self) -> std::cell::Ref<'_, $crate::input::input_definition::InputDefinition>); + $crate::delegate_to_inner!($field, fn set_name(&self, name: &str) -> anyhow::Result<()>); + $crate::delegate_to_inner!($field, fn get_name(&self) -> Option); + $crate::delegate_to_inner!($field, fn set_process_title(&self, title: &str)); + $crate::delegate_to_inner!($field, fn get_process_title(&self) -> Option); + $crate::delegate_to_inner!($field, fn set_hidden(&self, hidden: bool)); + $crate::delegate_to_inner!($field, fn is_hidden(&self) -> bool); + $crate::delegate_to_inner!($field, fn set_description(&self, description: &str)); + $crate::delegate_to_inner!($field, fn get_description(&self) -> String); + $crate::delegate_to_inner!($field, fn set_help(&self, help: &str)); + $crate::delegate_to_inner!($field, fn get_help(&self) -> String); + $crate::delegate_to_inner!($field, fn get_processed_help(&self) -> String); + $crate::delegate_to_inner!($field, fn set_aliases(&self, aliases: Vec) -> anyhow::Result<()>); + $crate::delegate_to_inner!($field, fn get_aliases(&self) -> Vec); + $crate::delegate_to_inner!($field, fn get_synopsis(&self, short: bool) -> String); + $crate::delegate_to_inner!($field, fn add_usage(&self, usage: &str)); + $crate::delegate_to_inner!($field, fn get_usages(&self) -> Vec); + $crate::delegate_to_inner!($field, fn get_helper(&self, name: &str) -> anyhow::Result>); + $crate::delegate_to_inner!($field, fn set_code(&self, code: Box shirabe_php_shim::PhpMixed>)); + $crate::delegate_to_inner!($field, fn get_code(&self) -> std::cell::Ref<'_, Option shirabe_php_shim::PhpMixed>>>); + $crate::delegate_to_inner!($field, fn ignore_validation_errors(&self)); + $crate::delegate_to_inner!($field, fn get_ignore_validation_errors(&self) -> bool); + }; +} + +/// Polymorphic interface for all commands (PHP's `Command` base class as seen by +/// callers that hold a command of unknown concrete type). +/// +/// The canonical behavior lives in `impl Command for CommandData`; subclasses forward +/// the state methods there and override the behavior hooks (`configure`/`execute`/...). +/// Object-safe so `dyn Command` works. All methods take `&self`; the command's mutable +/// state is interior-mutable (see [`CommandData`]). +pub trait Command: std::fmt::Debug + shirabe_php_shim::AsAny + shirabe_php_shim::PhpClass { + /// Configures the current command. + fn configure(&self) -> anyhow::Result<()> { + Ok(()) + } + + /// Executes the current command, returning 0 or an exit code. + /// + /// Concrete commands override this; reaching the default means a command class + /// forgot to implement it (PHP throws LogicException — a programming error here). + fn execute( + &self, + _input: std::rc::Rc>, + _output: std::rc::Rc>, + ) -> anyhow::Result { + panic!("You must override the execute() method in the concrete command class."); + } + + /// Interacts with the user before the InputDefinition is validated. + fn interact( + &self, + _input: std::rc::Rc>, + _output: std::rc::Rc>, + ) { + } + + /// Initializes the command after the input has been bound and before it is validated. + fn initialize( + &self, + _input: std::rc::Rc>, + _output: std::rc::Rc>, + ) -> anyhow::Result<()> { + Ok(()) + } + + /// Adds suggestions to `suggestions` for the current completion input. + /// + /// PHP's `complete` is `void` but can throw; errors are surfaced through `anyhow::Result` + /// so they propagate to `CompleteCommand::execute`'s catch-all (which turns them into + /// exit code 2), matching the PHP exception flow. + fn complete( + &self, + _input: &CompletionInput, + _suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + Ok(()) + } + + /// Whether this command proxies to another application/command (Composer's + /// `BaseCommand::isProxyCommand`). Exposed here so the `dyn Command` registry can detect proxy + /// commands without downcasting to the Composer `BaseCommand` trait; defaults to `false` and is + /// overridden by Composer proxy commands such as `GlobalCommand`. + fn is_proxy_command(&self) -> bool { + false + } + + /// Runs the command. + /// + /// Template method: it calls `self.initialize()`, `self.interact()` and + /// `self.execute()`, which dispatch to the concrete command's overrides. It must + /// not be overridden (except by proxy commands like `GlobalCommand`) nor delegated, + /// or that late binding breaks. + fn run( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + self.base_run(input, output) + } + + /// The base-class (`Command`) body of `run`, as PHP's `Command::run`. Proxy commands such as + /// `GlobalCommand` override `run` but still call `base_run` to delegate to the base behavior, + /// matching PHP's `parent::run($input, $output)`. It must not be overridden, or the late + /// binding of `initialize`/`interact`/`execute` to the concrete command breaks. + fn base_run( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + // add the application arguments and options + self.merge_application_definition(true); + + // bind the input against the command specific arguments/options + match input.borrow_mut().bind(&self.get_definition()) { + Ok(()) => {} + Err(e) => { + if !self.get_ignore_validation_errors() { + return Err(e); + } + } + } + + self.initialize(input.clone(), output.clone())?; + + if let Some(process_title) = self.get_process_title() { + // TODO(phase-c): PHP probes for cli_set_process_title / setproctitle availability. + if shirabe_php_shim::function_exists("cli_set_process_title") { + if !shirabe_php_shim::cli_set_process_title(&process_title) { + if shirabe_php_shim::PHP_OS == "Darwin" { + output.borrow_mut().writeln( + &["Running \"cli_set_process_title\" as an unprivileged user is not supported on MacOS.".to_string()], + output_interface::VERBOSITY_VERY_VERBOSE, + ); + } else { + shirabe_php_shim::cli_set_process_title(&process_title); + } + } + } else if shirabe_php_shim::function_exists("setproctitle") { + shirabe_php_shim::setproctitle(&process_title); + } else if output.borrow().get_verbosity() == output_interface::VERBOSITY_VERY_VERBOSE { + output.borrow_mut().writeln( + &["Install the proctitle PECL to be able to change the process title.".to_string()], + output_interface::OUTPUT_NORMAL, + ); + } + } + + if input.borrow().is_interactive() { + self.interact(input.clone(), output.clone()); + } + + // The command name argument is often omitted when a command is executed directly with its run() method. + // It would fail the validation if we didn't make sure the command argument is present, + // since it's required by the application. + if input.borrow().has_argument("command") + && matches!(input.borrow().get_argument("command")?, PhpMixed::Null) + { + let name = self.get_name(); + input + .borrow_mut() + .set_argument("command", PhpMixed::from(name))?; + } + + input.borrow_mut().validate()?; + + let status_code: PhpMixed = if self.get_code().is_some() { + let code = self.get_code(); + let code = code.as_ref().unwrap(); + code(&mut *input.borrow_mut(), &mut *output.borrow_mut()) + } else { + let executed = self.execute(input.clone(), output.clone())?; + // PHP also raises \TypeError when execute() does not return int; in this + // strongly-typed port execute() already returns an int, so the check is moot. + PhpMixed::from(executed) + }; + + // is_numeric($statusCode) ? (int) $statusCode : 0 + Ok(shirabe_php_shim::is_numeric_to_int(&status_code)) + } + + // --- state methods (canonical impl on `CommandData`; subclasses forward there) --- + + fn is_enabled(&self) -> bool; + + fn set_application( + &self, + application: Option>>, + ); + + fn get_application(&self) -> Option>>; + + fn set_helper_set(&self, helper_set: std::rc::Rc>); + + fn get_helper_set(&self) -> Option>>; + + fn merge_application_definition(&self, merge_args: bool); + + fn get_definition(&self) -> Ref<'_, InputDefinition>; + + fn get_native_definition(&self) -> Ref<'_, InputDefinition>; + + fn set_name(&self, name: &str) -> anyhow::Result<()>; + + fn get_name(&self) -> Option; + + fn set_process_title(&self, title: &str); + + fn get_process_title(&self) -> Option; + + fn set_hidden(&self, hidden: bool); + + fn is_hidden(&self) -> bool; + + fn set_description(&self, description: &str); + + fn get_description(&self) -> String; + + fn set_help(&self, help: &str); + + fn get_help(&self) -> String; + + fn get_processed_help(&self) -> String; + + fn set_aliases(&self, aliases: Vec) -> anyhow::Result<()>; + + fn get_aliases(&self) -> Vec; + + fn get_synopsis(&self, short: bool) -> String; + + fn add_usage(&self, usage: &str); + + fn get_usages(&self) -> Vec; + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result>; + + fn set_code( + &self, + code: Box PhpMixed>, + ); + + fn get_code( + &self, + ) -> Ref<'_, Option PhpMixed>>>; + + fn ignore_validation_errors(&self); + + fn get_ignore_validation_errors(&self) -> bool; +} + +impl shirabe_php_shim::PhpClass for CommandData { + fn php_class_name(&self) -> String { + panic!( + "php_class_name called on the base command state; concrete commands supply their class name" + ); + } +} + +impl Command for CommandData { + fn is_enabled(&self) -> bool { + true + } + + fn set_application( + &self, + application: Option>>, + ) { + *self.application.borrow_mut() = application.clone(); + if let Some(application) = application { + self.set_helper_set(application.borrow_mut().get_helper_set()); + } else { + *self.helper_set.borrow_mut() = None; + } + + *self.full_definition.borrow_mut() = None; + } + + fn get_application(&self) -> Option>> { + self.application.borrow().clone() + } + + fn set_helper_set(&self, helper_set: std::rc::Rc>) { + *self.helper_set.borrow_mut() = Some(helper_set); + } + + fn get_helper_set(&self) -> Option>> { + self.helper_set.borrow().clone() + } + + /// Merges the application definition with the command definition. + fn merge_application_definition(&self, merge_args: bool) { + let application = match &*self.application.borrow() { + None => return, + Some(application) => application.clone(), + }; + + // InputDefinition stores its entries as `Rc` / `Rc` while the + // setters take owned values, so the shared entries are cloned out (both types derive Clone). + let app_definition = application.borrow_mut().get_definition(); + + let mut full_definition = + InputDefinition::new(Vec::new()).expect("an empty InputDefinition cannot fail"); + + let own_options: Vec = self + .definition + .borrow() + .as_ref() + .unwrap() + .get_options() + .values() + .map(|option| (**option).clone()) + .collect(); + full_definition + .set_options(own_options) + .expect("the command's own options are already valid"); + + let app_options: Vec = app_definition + .borrow() + .get_options() + .values() + .map(|option| (**option).clone()) + .collect(); + full_definition + .add_options(app_options) + .expect("merging the application options cannot conflict here"); + + if merge_args { + let app_arguments: Vec = app_definition + .borrow() + .get_arguments() + .values() + .map(|argument| (**argument).clone()) + .collect(); + full_definition + .set_arguments(app_arguments) + .expect("the application arguments are already valid"); + + let own_arguments: Vec = self + .definition + .borrow() + .as_ref() + .unwrap() + .get_arguments() + .values() + .map(|argument| (**argument).clone()) + .collect(); + full_definition + .add_arguments(Some(own_arguments)) + .expect("merging the command's own arguments cannot conflict here"); + } else { + let own_arguments: Vec = self + .definition + .borrow() + .as_ref() + .unwrap() + .get_arguments() + .values() + .map(|argument| (**argument).clone()) + .collect(); + full_definition + .set_arguments(own_arguments) + .expect("the command's own arguments are already valid"); + } + + *self.full_definition.borrow_mut() = Some(full_definition); + } + + fn get_definition(&self) -> Ref<'_, InputDefinition> { + if self.full_definition.borrow().is_some() { + Ref::map(self.full_definition.borrow(), |full_definition| { + full_definition.as_ref().unwrap() + }) + } else { + self.get_native_definition() + } + } + + fn get_native_definition(&self) -> Ref<'_, InputDefinition> { + Ref::map(self.definition.borrow(), |definition| match definition { + Some(definition) => definition, + None => { + // PHP throws LogicException; `definition` is set in `new()`, so None is a + // programming error (forgot to call the parent constructor). + panic!( + "Command class is not correctly initialized. You probably forgot to call the parent constructor." + ); + } + }) + } + + fn set_name(&self, name: &str) -> anyhow::Result<()> { + if let Err(e) = self.validate_name(name)? { + return Err(e.into()); + } + + *self.name.borrow_mut() = Some(name.to_string()); + + Ok(()) + } + + fn get_name(&self) -> Option { + self.name.borrow().clone() + } + + fn set_process_title(&self, title: &str) { + *self.process_title.borrow_mut() = Some(title.to_string()); + } + + fn get_process_title(&self) -> Option { + self.process_title.borrow().clone() + } + + fn set_hidden(&self, hidden: bool) { + self.hidden.set(hidden); + } + + fn is_hidden(&self) -> bool { + self.hidden.get() + } + + fn set_description(&self, description: &str) { + *self.description.borrow_mut() = description.to_string(); + } + + fn get_description(&self) -> String { + self.description.borrow().clone() + } + + fn set_help(&self, help: &str) { + *self.help.borrow_mut() = help.to_string(); + } + + fn get_help(&self) -> String { + self.help.borrow().clone() + } + + fn get_processed_help(&self) -> String { + let name = self.name.borrow().clone(); + let is_single_command = match &*self.application.borrow() { + Some(application) => application.borrow().is_single_command(), + None => false, + }; + + let placeholders = [ + "%command.name%".to_string(), + "%command.full_name%".to_string(), + ]; + let php_self = shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .php_self() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let replacements = [ + name.clone().unwrap_or_default(), + if is_single_command { + php_self + } else { + format!("{} {}", php_self, name.unwrap_or_default()) + }, + ]; + + let help = self.get_help(); + let subject = if help.is_empty() { + self.get_description() + } else { + help + }; + + shirabe_php_shim::str_replace_array(&placeholders, &replacements, &subject) + } + + fn set_aliases(&self, aliases: Vec) -> anyhow::Result<()> { + let mut list = Vec::new(); + + for alias in &aliases { + if let Err(e) = self.validate_name(alias)? { + return Err(e.into()); + } + list.push(alias.clone()); + } + + // PHP: `\is_array($aliases) ? $aliases : $list`. Here `aliases` is always an + // array (Vec), so the result is `aliases`; `list` mirrors the validation loop. + *self.aliases.borrow_mut() = aliases; + + Ok(()) + } + + fn get_aliases(&self) -> Vec { + self.aliases.borrow().clone() + } + + fn get_synopsis(&self, short: bool) -> String { + let key = if short { "short" } else { "long" }.to_string(); + + if !self.synopsis.borrow().contains_key(&key) { + let value = format!( + "{} {}", + self.name.borrow().clone().unwrap_or_default(), + self.definition + .borrow() + .as_ref() + .unwrap() + .get_synopsis(short) + ) + .trim() + .to_string(); + self.synopsis.borrow_mut().insert(key.clone(), value); + } + + self.synopsis.borrow()[&key].clone() + } + + fn add_usage(&self, usage: &str) { + let mut usage = usage.to_string(); + let name = self.name.borrow().clone().unwrap_or_default(); + if !usage.starts_with(&name) { + usage = format!("{} {}", name, usage); + } + + self.usages.borrow_mut().push(usage); + } + + fn get_usages(&self) -> Vec { + self.usages.borrow().clone() + } + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result> { + let helper_set_ref = self.helper_set.borrow(); + let helper_set = match &*helper_set_ref { + None => { + return Ok(Err(crate::exception::logic_exception::LogicException::new( + format!( + "Cannot retrieve helper \"{}\" because there is no HelperSet defined. Did you forget to add your command to the application or to set the application on the command using the setApplication() method? You can also set the HelperSet directly using the setHelperSet() method.", + name + ), + ))); + } + Some(helper_set) => helper_set, + }; + + // TODO(plugin): PHP's Command::getHelper($name) looks a helper up by string via + // HelperSet::get($name). The HelperSet is now a closed set exposing only typed getters + // (get_formatter/get_question/...), so a string-keyed lookup no longer exists. Callers + // should use the typed getters on the HelperSet directly; restoring name-based lookup is + // deferred until the plugin API (which is the only source of dynamically named helpers). + let _ = helper_set; + todo!() + } + + fn set_code( + &self, + code: Box PhpMixed>, + ) { + // TODO(php-runtime): PHP rebinds an unbound Closure's $this to the command instance via + // ReflectionFunction/Closure::bind. Rust closures have no `$this` rebinding; + // the closure is stored as-is. + *self.code.borrow_mut() = Some(code); + } + + fn get_code( + &self, + ) -> Ref<'_, Option PhpMixed>>> + { + self.code.borrow() + } + + fn ignore_validation_errors(&self) { + self.ignore_validation_errors.set(true); + } + + fn get_ignore_validation_errors(&self) -> bool { + self.ignore_validation_errors.get() + } +} + +impl std::fmt::Debug for CommandData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CommandData") + .field("name", &self.name.borrow()) + .field("aliases", &self.aliases.borrow()) + .field("hidden", &self.hidden.get()) + .field("description", &self.description.borrow()) + .finish_non_exhaustive() + } +} diff --git a/crates/shirabe-symfony-console/src/command/complete_command.rs b/crates/shirabe-symfony-console/src/command/complete_command.rs new file mode 100644 index 00000000..3dad2609 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/complete_command.rs @@ -0,0 +1,415 @@ +//! ref: composer/vendor/symfony/console/Command/CompleteCommand.php + +use crate::command::command::{Command, CommandData}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::completion::output::bash_completion_output::BashCompletionOutput; +use crate::completion::output::completion_output_interface::CompletionOutputInterface; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use std::ops::{Deref, DerefMut}; + +/// Responsible for providing the values to the shell completion. +#[derive(Debug)] +pub struct CompleteCommand { + inner: CommandData, + completion_outputs: IndexMap, + is_debug: std::cell::Cell, +} + +impl_php_class!( + CompleteCommand, + r"Symfony\Component\Console\Command\CompleteCommand" +); + +impl Deref for CompleteCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for CompleteCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl CompleteCommand { + pub const DEFAULT_NAME: &'static str = "|_complete"; + pub const DEFAULT_DESCRIPTION: &'static str = + "Internal command to provide shell completion suggestions"; + + /// @param completion_outputs A list of additional completion outputs, with shell name as + /// key and FQCN as value + pub fn new(completion_outputs: IndexMap) -> anyhow::Result { + // must be set before the parent constructor, as the property value is used in configure() + let mut completion_outputs = completion_outputs; + // $completionOutputs + ['bash' => BashCompletionOutput::class] + completion_outputs + .entry("bash".to_string()) + .or_insert_with(|| { + PhpMixed::from( + "Symfony\\Component\\Console\\Completion\\Output\\BashCompletionOutput" + .to_string(), + ) + }); + + let this = Self { + inner: CommandData::new(None), + completion_outputs, + is_debug: std::cell::Cell::new(false), + }; + // PHP: static $defaultName = '|_complete' / $defaultDescription, applied by the parent + // constructor before configure(). + this.inner.apply_default_name(Self::DEFAULT_NAME)?; + this.inner.set_description(Self::DEFAULT_DESCRIPTION); + this.configure()?; + + Ok(this) + } + + fn create_completion_input( + &self, + input: &dyn InputInterface, + ) -> anyhow::Result { + let current_index = input.get_option("current")?; + if !current_index.to_bool() || !shirabe_php_shim::ctype_digit(¤t_index.to_string()) { + anyhow::bail!(shirabe_php_shim::RuntimeException::new( + "The \"--current\" option must be set and it must be an integer.".to_string() + )); + } + + let tokens: Vec = match input.get_option("input")?.as_list() { + Some(list) => list.iter().map(|v| v.to_string()).collect(), + None => Vec::new(), + }; + let mut completion_input = CompletionInput::from_tokens( + tokens, + current_index.to_string().parse::().unwrap_or(0), + )?; + + // try { $completionInput->bind(...); } catch (ExceptionInterface $e) {} + let application = self.get_application().unwrap(); + let definition = application.borrow_mut().get_definition(); + let _ = completion_input.bind(&definition.borrow()); + + Ok(completion_input) + } + + fn find_command( + &self, + completion_input: &CompletionInput, + _output: &dyn OutputInterface, + ) -> Option>> { + // try { ... } catch (CommandNotFoundException $e) {} + let input_name = completion_input.get_first_argument()?; + + let application = self.get_application().unwrap(); + // CommandNotFoundException is caught and swallowed by returning None. + application.borrow_mut().find(&input_name).ok() + } + + fn log(&self, messages: &str) { + self.log_many(vec![messages.to_string()]); + } + + fn log_many(&self, messages: Vec) { + if !self.is_debug.get() { + return; + } + + let command_name = shirabe_php_shim::basename( + &shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .argv() + .next() + .unwrap_or_default() + .to_string_lossy(), + ); + shirabe_php_shim::file_put_contents3( + &format!( + "{}/sf_{}.log", + shirabe_php_shim::sys_get_temp_dir(), + command_name + ), + &(messages.join(shirabe_php_shim::PHP_EOL) + shirabe_php_shim::PHP_EOL), + shirabe_php_shim::FILE_APPEND, + ); + } +} + +fn get_class_of_command(command: &std::rc::Rc>) -> String { + // LazyCommand is intentionally not ported. + command.borrow().php_class_name() +} + +fn get_definition_options( + command: &std::rc::Rc>, +) -> Vec> { + command + .borrow() + .get_definition() + .get_options() + .values() + .cloned() + .collect() +} + +/// new $completionOutput(); +fn instantiate_completion_output(class: &PhpMixed) -> Box { + match class.to_string().as_str() { + "Symfony\\Component\\Console\\Completion\\Output\\BashCompletionOutput" => { + Box::new(BashCompletionOutput) + } + // completion_outputs only ever registers the bash output (Composer registers no extra + // ones), so any other FQCN is a programming error. + other => panic!("unknown completion output class: {}", other), + } +} + +impl Command for CompleteCommand { + fn configure(&self) -> anyhow::Result<()> { + let shells = self + .completion_outputs + .keys() + .cloned() + .collect::>() + .join("\", \""); + self.inner + .add_option( + "shell", + PhpMixed::from("s".to_string()), + Some(InputOption::VALUE_REQUIRED), + &format!("The shell type (\"{}\")", shells), + PhpMixed::Null, + )? + .add_option( + "input", + PhpMixed::from("i".to_string()), + Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + "An array of input tokens (e.g. COMP_WORDS or argv)", + PhpMixed::Null, + )? + .add_option( + "current", + PhpMixed::from("c".to_string()), + Some(InputOption::VALUE_REQUIRED), + "The index of the \"input\" array that the cursor is in (e.g. COMP_CWORD)", + PhpMixed::Null, + )? + .add_option( + "symfony", + PhpMixed::from("S".to_string()), + Some(InputOption::VALUE_REQUIRED), + "The version of the completion script", + PhpMixed::Null, + )?; + + Ok(()) + } + + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + let _ = (input, output); + self.is_debug.set(shirabe_php_shim::filter_var_boolean( + &shirabe_php_shim::getenv("SYMFONY_COMPLETION_DEBUG") + .unwrap_or_default() + .to_string_lossy(), + )); + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + // try { ... } catch (\Throwable $e) { ...; if ($output->isDebug()) { throw $e; } return 2; } + let result: anyhow::Result = (|| { + // uncomment when a bugfix or BC break has been introduced in the shell completion scripts + // $version = $input->getOption('symfony'); + // if ($version && version_compare($version, 'x.y', '>=')) { + // $message = sprintf('Completion script version is not supported ("%s" given, ">=x.y" required).', $version); + // $this->log($message); + // $output->writeln($message.' Install the Symfony completion script again by using the "completion" command.'); + // return 126; + // } + + let shell = input.borrow().get_option("shell")?; + if !shell.to_bool() { + anyhow::bail!(shirabe_php_shim::RuntimeException::new( + "The \"--shell\" option must be set.".to_string() + )); + } + + let completion_output = self + .completion_outputs + .get(&shell.to_string()) + .cloned() + .unwrap_or(PhpMixed::Bool(false)); + if !completion_output.to_bool() { + anyhow::bail!(shirabe_php_shim::RuntimeException::new(format!( + "Shell completion is not supported for your shell: \"{}\" (supported: \"{}\").", + shell, + self.completion_outputs + .keys() + .cloned() + .collect::>() + .join("\", \"") + ))); + } + + let mut completion_input = self.create_completion_input(&*input.borrow())?; + let mut suggestions = CompletionSuggestions::new(); + + self.log_many(vec![ + String::new(), + format!( + "{}", + shirabe_php_shim::date("Y-m-d H:i:s", None) + ), + "Input: (\"|\" indicates the cursor position)".to_string(), + format!(" {}", completion_input.to_string()), + "Command:".to_string(), + format!( + " {}", + shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .argv() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>() + .join(" ") + ), + "Messages:".to_string(), + ]); + + let command = self.find_command(&completion_input, &*output.borrow()); + match command { + None => { + self.log(" No command found, completing using the Application class."); + + let application = self.get_application().unwrap(); + application + .borrow_mut() + .complete(&completion_input, &mut suggestions)?; + } + Some(command) + if completion_input.must_suggest_argument_values_for("command") + && command.borrow().get_name().as_deref() + != Some(&completion_input.get_completion_value()) + && !command + .borrow() + .get_aliases() + .iter() + .any(|a| a == &completion_input.get_completion_value()) => + { + self.log(" No command found, completing using the Application class."); + + // expand shortcut names ("cache:cl") into their full name ("cache:clear") + let mut values = vec![command.borrow().get_name()]; + values.extend(command.borrow().get_aliases().into_iter().map(Some)); + suggestions.suggest_values( + values + .into_iter() + .flatten() + .filter(|v| !v.is_empty()) + .map(StringOrSuggestion::String) + .collect(), + ); + } + Some(command) => { + // PHP: $command->mergeApplicationDefinition() — $mergeArgs defaults to true. + command.borrow().merge_application_definition(true); + completion_input.bind(&command.borrow().get_definition())?; + + if CompletionInput::TYPE_OPTION_NAME == completion_input.get_completion_type() { + self.log(&format!( + " Completing option names for the {} command.", + get_class_of_command(&command) + )); + + suggestions.suggest_options(get_definition_options(&command)); + } else { + self.log_many(vec![ + format!( + " Completing using the {} class.", + get_class_of_command(&command) + ), + format!( + " Completing {} for {}", + completion_input.get_completion_type(), + completion_input.get_completion_name().unwrap_or_default() + ), + ]); + let compval = completion_input.get_completion_value(); + if !compval.is_empty() { + self.log(&format!(" Current value: {}", compval)); + } + + command + .borrow() + .complete(&completion_input, &mut suggestions)?; + } + } + } + + // $completionOutput = new $completionOutput(); + let completion_output: Box = + instantiate_completion_output(&completion_output); + + self.log("Suggestions:"); + let option_suggestions = suggestions.get_option_suggestions(); + if !option_suggestions.is_empty() { + self.log(&format!( + " --{}", + option_suggestions + .iter() + .map(|o| o.get_name()) + .collect::>() + .join(" --") + )); + } else { + let value_suggestions: Vec = suggestions + .get_value_suggestions() + .iter() + .map(|s| s.get_value()) + .collect(); + if !value_suggestions.is_empty() { + self.log(&format!(" {}", value_suggestions.join(" "))); + } else { + self.log(" No suggestions were provided"); + } + } + + completion_output.write(&suggestions, &*output.borrow_mut()); + + Ok(0) + })(); + + match result { + Ok(code) => Ok(code), + Err(e) => { + self.log_many(vec!["Error!".to_string(), format!("{}", e)]); + + if output.borrow().is_debug() { + return Err(e); + } + + Ok(2) + } + } + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/dump_completion_command.rs b/crates/shirabe-symfony-console/src/command/dump_completion_command.rs new file mode 100644 index 00000000..efe116a9 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/dump_completion_command.rs @@ -0,0 +1,295 @@ +//! ref: composer/vendor/symfony/console/Command/DumpCompletionCommand.php + +use crate::command::command::{Command, CommandData}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::input::input_argument::InputArgument; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::{self, OutputInterface}; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use shirabe_symfony_process::process::Process; +use std::ops::{Deref, DerefMut}; + +/// __DIR__.'/../Resources/completion.bash', embedded at compile time (this port ships as a +/// single binary and does not install the Resources directory alongside it). +const COMPLETION_BASH: &str = include_str!("../Resources/completion.bash"); + +/// Dumps the completion script for the current shell. +#[derive(Debug)] +pub struct DumpCompletionCommand { + inner: CommandData, +} + +impl_php_class!( + DumpCompletionCommand, + r"Symfony\Component\Console\Command\DumpCompletionCommand" +); + +impl Deref for DumpCompletionCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for DumpCompletionCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Default for DumpCompletionCommand { + fn default() -> Self { + Self::new() + } +} + +impl DumpCompletionCommand { + pub const DEFAULT_NAME: &'static str = "completion"; + pub const DEFAULT_DESCRIPTION: &'static str = "Dump the shell completion script"; + + pub fn new() -> Self { + let command = DumpCompletionCommand { + inner: CommandData::new(None), + }; + // PHP: static $defaultName = 'completion' / $defaultDescription, applied by the parent + // constructor before configure(). + command + .inner + .apply_default_name(Self::DEFAULT_NAME) + .expect("DumpCompletionCommand default name is valid"); + command.inner.set_description(Self::DEFAULT_DESCRIPTION); + command + .configure() + .expect("DumpCompletionCommand::configure uses static, valid metadata"); + command + } + + pub fn complete_impl( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + if input.must_suggest_argument_values_for("shell") { + suggestions.suggest_values( + self.get_supported_shells()? + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + Ok(()) + } + + fn guess_shell() -> String { + shirabe_php_shim::basename( + &shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .get("SHELL") + .unwrap_or_default() + .to_string_lossy(), + ) + } + + /// The PHP closure captures `$output` by reference; `Process::run` needs a `'static` + /// callback, so the shared handle is moved into it instead. + fn tail_debug_log( + &self, + command_name: &str, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + let debug_file = format!( + "{}/sf_{}.log", + shirabe_php_shim::sys_get_temp_dir(), + command_name + ); + if !shirabe_php_shim::file_exists(&debug_file) { + shirabe_php_shim::touch(&debug_file); + } + // new Process(['tail', '-f', $debugFile], null, null, null, 0) — timeout 0 disables it; + // like PHP, this tails forever until the user interrupts. + let mut process = Process::new( + vec!["tail".to_string(), "-f".to_string(), debug_file], + None, + None, + PhpMixed::Null, + Some(0.0), + )?; + process.run( + Some(Box::new(move |_type: &str, line: &str| { + output.borrow_mut().write( + &[line.to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + false + })), + indexmap::IndexMap::new(), + )?; + Ok(()) + } + + fn get_supported_shells(&self) -> anyhow::Result> { + // Deviation from PHP: the PHP implementation scans __DIR__.'/../Resources/' with a + // DirectoryIterator at runtime; the resources are embedded at compile time in this + // port, so the supported shells are a static list. + Ok(vec!["bash".to_string()]) + } +} + +impl Command for DumpCompletionCommand { + fn configure(&self) -> anyhow::Result<()> { + let full_command = shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .php_self() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let command_name = shirabe_php_shim::basename(&full_command); + // @realpath($fullCommand) ?: $fullCommand + let full_command = match shirabe_php_shim::realpath(&full_command) { + Some(p) if !p.is_empty() => p, + _ => full_command, + }; + + self.inner.set_help(&format!( + "The %command.name% command dumps the shell completion script required\n\ + to use shell autocompletion (currently only bash completion is supported).\n\ + \n\ + Static installation\n\ + -------------------\n\ + \n\ + Dump the script to a global completion file and restart your shell:\n\ + \n\ + \x20\x20\x20\x20%command.full_name% bash | sudo tee /etc/bash_completion.d/{command_name}\n\ + \n\ + Or dump the script to a local file and source it:\n\ + \n\ + \x20\x20\x20\x20%command.full_name% bash > completion.sh\n\ + \n\ + \x20\x20\x20\x20# source the file whenever you use the project\n\ + \x20\x20\x20\x20source completion.sh\n\ + \n\ + \x20\x20\x20\x20# or add this line at the end of your \"~/.bashrc\" file:\n\ + \x20\x20\x20\x20source /path/to/completion.sh\n\ + \n\ + Dynamic installation\n\ + --------------------\n\ + \n\ + Add this to the end of your shell configuration file (e.g. \"~/.bashrc\"):\n\ + \n\ + \x20\x20\x20\x20eval \"$({full_command} completion bash)\"", + )); + self.inner.add_argument( + "shell", + Some(InputArgument::OPTIONAL), + "The shell type (e.g. \"bash\"), the value of the \"$SHELL\" env var will be used if this is not given", + PhpMixed::Null, + )?; + self.inner.add_option( + "debug", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "Tail the completion debug log", + PhpMixed::Null, + )?; + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let command_name = shirabe_php_shim::basename( + &shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .argv() + .next() + .unwrap_or_default() + .to_string_lossy(), + ); + + if input.borrow().get_option("debug")?.to_bool() { + self.tail_debug_log(&command_name, output.clone())?; + + return Ok(0); + } + + let shell = match input.borrow().get_argument("shell")?.as_string() { + Some(s) => s.to_string(), + None => Self::guess_shell(), + }; + // __DIR__.'/../Resources/completion.'.$shell — resolved against the embedded + // resources; a shell without an embedded script is PHP's !file_exists() branch. + let completion_file = match shell.as_str() { + "bash" => Some(COMPLETION_BASH), + _ => None, + }; + let Some(completion_file) = completion_file else { + let supported_shells = self.get_supported_shells()?; + + // if ($output instanceof ConsoleOutputInterface) { $output = $output->getErrorOutput(); } + let output = { + let error_output = output + .borrow() + .as_console_output() + .map(|console_output| console_output.get_error_output()); + error_output.unwrap_or_else(|| output.clone()) + }; + if !shell.is_empty() { + output.borrow_mut().writeln( + &[format!( + "Detected shell \"{}\", which is not supported by Symfony shell completion (supported shells: \"{}\").", + shell, + supported_shells.join("\", \"") + )], + output_interface::OUTPUT_NORMAL, + ); + } else { + output.borrow_mut().writeln( + &[format!( + "Shell not detected, Symfony shell completion only supports \"{}\").", + supported_shells.join("\", \"") + )], + output_interface::OUTPUT_NORMAL, + ); + } + + return Ok(2); + }; + + let application = self.get_application().unwrap(); + let version = application.borrow().get_version(); + output.borrow_mut().write( + &[shirabe_php_shim::str_replace_arrays( + &[ + "{{ COMMAND_NAME }}".to_string(), + "{{ VERSION }}".to_string(), + ], + &[command_name, version], + completion_file, + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + Ok(0) + } + + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + self.complete_impl(input, suggestions) + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/help_command.rs b/crates/shirabe-symfony-console/src/command/help_command.rs new file mode 100644 index 00000000..9472d144 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/help_command.rs @@ -0,0 +1,171 @@ +//! ref: composer/vendor/symfony/console/Command/HelpCommand.php + +use crate::command::command::{Command, CommandData, SetDefinitionArg}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor_interface::DescribableObject; +use crate::helper::descriptor_helper::DescriptorHelper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::DefinitionItem; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use std::ops::{Deref, DerefMut}; + +/// HelpCommand displays the help for a given command. +#[derive(Debug)] +pub struct HelpCommand { + inner: CommandData, + command: std::cell::RefCell>>>, +} + +impl_php_class!( + HelpCommand, + r"Symfony\Component\Console\Command\HelpCommand" +); + +impl Deref for HelpCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for HelpCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Default for HelpCommand { + fn default() -> Self { + Self::new() + } +} + +impl HelpCommand { + pub fn new() -> Self { + let command = HelpCommand { + inner: CommandData::new(None), + command: std::cell::RefCell::new(None), + }; + command + .configure() + .expect("HelpCommand::configure uses static, valid metadata"); + command + } + + pub fn set_command(&self, command: std::rc::Rc>) { + *self.command.borrow_mut() = Some(command); + } + + pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + if input.must_suggest_argument_values_for("command_name") { + let application = self.get_application().unwrap(); + let mut descriptor = ApplicationDescription::new(application, None, false); + suggestions.suggest_values( + descriptor + .get_commands() + .keys() + .cloned() + .map(StringOrSuggestion::String) + .collect(), + ); + + return; + } + + if input.must_suggest_option_values_for("format") { + let helper = DescriptorHelper::new(); + suggestions.suggest_values( + helper + .get_formats() + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + } +} + +impl Command for HelpCommand { + fn configure(&self) -> anyhow::Result<()> { + self.inner.ignore_validation_errors(); + + self.inner.set_name("help")?; + self.inner.set_definition(SetDefinitionArg::Array(vec![ + DefinitionItem::InputArgument(InputArgument::new( + "command_name".to_string(), + Some(InputArgument::OPTIONAL), + "The command name".to_string(), + PhpMixed::from("help".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "format", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED), + "The output format (txt, xml, json, or md)".to_string(), + PhpMixed::from("txt".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "raw", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To output raw command help".to_string(), + PhpMixed::Null, + )?), + ])); + self.inner.set_description("Display help for a command"); + self.inner.set_help( + "The %command.name% command displays help for a given command:\n\ + \n\ + \x20\x20%command.full_name% list\n\ + \n\ + You can also output the help in other formats by using the --format option:\n\ + \n\ + \x20\x20%command.full_name% --format=xml list\n\ + \n\ + To display the list of available commands, please use the list command.", + ); + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + if self.command.borrow().is_none() { + let application = self.get_application().unwrap(); + let command_name = input.borrow().get_argument("command_name")?.to_string(); + let found = application.borrow_mut().find(&command_name)?; + *self.command.borrow_mut() = Some(found); + } + + let mut helper = DescriptorHelper::new(); + let object = DescribableObject::Command(self.command.borrow().clone().unwrap()); + let mut options = indexmap::IndexMap::new(); + options.insert("format".to_string(), input.borrow().get_option("format")?); + options.insert("raw_text".to_string(), input.borrow().get_option("raw")?); + helper.describe2(output.clone(), object, options)?; + + *self.command.borrow_mut() = None; + + Ok(0) + } + + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + self.complete_impl(input, suggestions); + Ok(()) + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/list_command.rs b/crates/shirabe-symfony-console/src/command/list_command.rs new file mode 100644 index 00000000..fb0bb770 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/list_command.rs @@ -0,0 +1,172 @@ +//! ref: composer/vendor/symfony/console/Command/ListCommand.php + +use crate::command::command::{Command, CommandData, SetDefinitionArg}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor_interface::DescribableObject; +use crate::helper::descriptor_helper::DescriptorHelper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::DefinitionItem; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use std::ops::{Deref, DerefMut}; + +/// ListCommand displays the list of all available commands for the application. +#[derive(Debug)] +pub struct ListCommand { + inner: CommandData, +} + +impl_php_class!( + ListCommand, + r"Symfony\Component\Console\Command\ListCommand" +); + +impl Deref for ListCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for ListCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Default for ListCommand { + fn default() -> Self { + Self::new() + } +} + +impl ListCommand { + pub fn new() -> Self { + let command = ListCommand { + inner: CommandData::new(None), + }; + command + .configure() + .expect("ListCommand::configure uses static, valid metadata"); + command + } + + pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + if input.must_suggest_argument_values_for("namespace") { + let application = self.get_application().unwrap(); + let mut descriptor = ApplicationDescription::new(application, None, false); + suggestions.suggest_values( + descriptor + .get_namespaces() + .keys() + .cloned() + .map(StringOrSuggestion::String) + .collect(), + ); + + return; + } + + if input.must_suggest_option_values_for("format") { + let helper = DescriptorHelper::new(); + suggestions.suggest_values( + helper + .get_formats() + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + } +} + +impl Command for ListCommand { + fn configure(&self) -> anyhow::Result<()> { + self.inner.set_name("list")?; + self.inner.set_definition(SetDefinitionArg::Array(vec![ + DefinitionItem::InputArgument(InputArgument::new( + "namespace".to_string(), + Some(InputArgument::OPTIONAL), + "The namespace name".to_string(), + PhpMixed::Null, + )?), + DefinitionItem::InputOption(InputOption::new( + "raw", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To output raw command list".to_string(), + PhpMixed::Null, + )?), + DefinitionItem::InputOption(InputOption::new( + "format", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED), + "The output format (txt, xml, json, or md)".to_string(), + PhpMixed::from("txt".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "short", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To skip describing commands' arguments".to_string(), + PhpMixed::Null, + )?), + ])); + self.inner.set_description("List commands"); + self.inner.set_help( + "The %command.name% command lists all commands:\n\ + \n\ + \x20\x20%command.full_name%\n\ + \n\ + You can also display the commands for a specific namespace:\n\ + \n\ + \x20\x20%command.full_name% test\n\ + \n\ + You can also output the information in other formats by using the --format option:\n\ + \n\ + \x20\x20%command.full_name% --format=xml\n\ + \n\ + It's also possible to get raw list of commands (useful for embedding command runner):\n\ + \n\ + \x20\x20%command.full_name% --raw", + ); + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let mut helper = DescriptorHelper::new(); + let object = DescribableObject::Application(self.get_application().unwrap()); + let mut options = indexmap::IndexMap::new(); + options.insert("format".to_string(), input.borrow().get_option("format")?); + options.insert("raw_text".to_string(), input.borrow().get_option("raw")?); + options.insert( + "namespace".to_string(), + input.borrow().get_argument("namespace")?, + ); + options.insert("short".to_string(), input.borrow().get_option("short")?); + helper.describe2(output.clone(), object, options)?; + + Ok(0) + } + + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + self.complete_impl(input, suggestions); + Ok(()) + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/signalable_command_interface.rs b/crates/shirabe-symfony-console/src/command/signalable_command_interface.rs new file mode 100644 index 00000000..8777eb5e --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/signalable_command_interface.rs @@ -0,0 +1,10 @@ +//! ref: composer/vendor/symfony/console/Command/SignalableCommandInterface.php + +/// Interface for command reacting to signal. +pub trait SignalableCommandInterface { + /// Returns the list of signals to subscribe. + fn get_subscribed_signals(&self) -> Vec; + + /// The method will be called when the application is signaled. + fn handle_signal(&mut self, signal: i64); +} -- cgit v1.3.1-4-g156e