diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-09 11:19:03 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-09 11:19:03 +0900 |
| commit | 6051927c8fa32cfffa102d2a170c5a6cf747a1b9 (patch) | |
| tree | 3fe6769c90cb03ca8f1b9b825e38ad459182361e /crates/shirabe-symfony-console/src/descriptor | |
| parent | e3e8806aec771e482899ed3470e920f7b291fa95 (diff) | |
| download | php-shirabe-6051927c8fa32cfffa102d2a170c5a6cf747a1b9.tar.gz php-shirabe-6051927c8fa32cfffa102d2a170c5a6cf747a1b9.tar.zst php-shirabe-6051927c8fa32cfffa102d2a170c5a6cf747a1b9.zip | |
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) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-symfony-console/src/descriptor')
7 files changed, 2090 insertions, 0 deletions
diff --git a/crates/shirabe-symfony-console/src/descriptor/application_description.rs b/crates/shirabe-symfony-console/src/descriptor/application_description.rs new file mode 100644 index 00000000..d63f0be2 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/application_description.rs @@ -0,0 +1,189 @@ +//! ref: composer/vendor/symfony/console/Descriptor/ApplicationDescription.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::exception::command_not_found_exception::CommandNotFoundException; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// @internal +#[derive(Debug)] +pub struct ApplicationDescription { + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + namespace: Option<String>, + show_hidden: bool, + + /// @var array + /// Each namespace entry is `['id' => string, 'commands' => string[]]`. + namespaces: Option<IndexMap<String, IndexMap<String, PhpMixed>>>, + + /// @var array<string, Command> + commands: Option<IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>>>, + + /// @var array<string, Command> + aliases: Option<IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>>>, +} + +impl ApplicationDescription { + pub const GLOBAL_NAMESPACE: &'static str = "_global"; + + pub fn new( + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + namespace: Option<String>, + show_hidden: bool, + ) -> Self { + ApplicationDescription { + application, + namespace, + show_hidden, + namespaces: None, + commands: None, + aliases: None, + } + } + + pub fn get_namespaces(&mut self) -> IndexMap<String, IndexMap<String, PhpMixed>> { + if self.namespaces.is_none() { + self.inspect_application(); + } + + self.namespaces.clone().unwrap() + } + + pub fn get_commands( + &mut self, + ) -> &IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>> { + if self.commands.is_none() { + self.inspect_application(); + } + + self.commands.as_ref().unwrap() + } + + /// @throws CommandNotFoundException + pub fn get_command( + &self, + name: &str, + ) -> anyhow::Result<std::rc::Rc<std::cell::RefCell<dyn Command>>> { + let in_commands = self + .commands + .as_ref() + .map(|c| c.contains_key(name)) + .unwrap_or(false); + let in_aliases = self + .aliases + .as_ref() + .map(|a| a.contains_key(name)) + .unwrap_or(false); + if !in_commands && !in_aliases { + return Err(CommandNotFoundException::new( + format!("Command \"{}\" does not exist.", name), + vec![], + 0, + ) + .into()); + } + + Ok(self + .commands + .as_ref() + .and_then(|c| c.get(name)) + .cloned() + .unwrap_or_else(|| self.aliases.as_ref().unwrap().get(name).unwrap().clone())) + } + + fn inspect_application(&mut self) { + self.commands = Some(IndexMap::new()); + self.namespaces = Some(IndexMap::new()); + + let namespace_filter = match &self.namespace { + Some(ns) if !ns.is_empty() => { + Some(self.application.borrow_mut().find_namespace(ns).unwrap()) + } + _ => None, + }; + let all = self + .application + .borrow_mut() + .all(namespace_filter.as_deref()) + .unwrap(); + for (namespace, commands) in self.sort_commands(all) { + let mut names: Vec<String> = vec![]; + + for (name, command) in commands { + let command_name = command.borrow().get_name(); + let is_hidden = command.borrow().is_hidden(); + if command_name.is_none() + || command_name.as_deref() == Some("") + || (!self.show_hidden && is_hidden) + { + continue; + } + + if command_name.as_deref() == Some(name.as_str()) { + self.commands + .as_mut() + .unwrap() + .insert(name.clone(), command); + } else { + self.aliases + .get_or_insert_with(IndexMap::new) + .insert(name.clone(), command); + } + + names.push(name); + } + + let mut entry: IndexMap<String, PhpMixed> = IndexMap::new(); + entry.insert("id".to_string(), PhpMixed::String(namespace.clone())); + entry.insert( + "commands".to_string(), + PhpMixed::List(names.into_iter().map(PhpMixed::String).collect()), + ); + self.namespaces.as_mut().unwrap().insert(namespace, entry); + } + } + + fn sort_commands( + &self, + commands: IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>>, + ) -> IndexMap<String, IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>>> { + let mut namespaced_commands: IndexMap< + String, + IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>>, + > = IndexMap::new(); + let mut global_commands: IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>> = + IndexMap::new(); + let mut sorted_commands: IndexMap< + String, + IndexMap<String, std::rc::Rc<std::cell::RefCell<dyn Command>>>, + > = IndexMap::new(); + for (name, command) in commands { + let key = self.application.borrow().extract_namespace(&name, Some(1)); + if ["", Self::GLOBAL_NAMESPACE].contains(&key.as_str()) { + global_commands.insert(name, command); + } else { + namespaced_commands + .entry(key) + .or_default() + .insert(name, command); + } + } + + if !global_commands.is_empty() { + global_commands.sort_keys(); + sorted_commands.insert(Self::GLOBAL_NAMESPACE.to_string(), global_commands); + } + + if !namespaced_commands.is_empty() { + // ksort($namespacedCommands, \SORT_STRING) + namespaced_commands.sort_keys(); + for (key, mut commands_set) in namespaced_commands { + commands_set.sort_keys(); + sorted_commands.insert(key, commands_set); + } + } + + sorted_commands + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/descriptor.rs new file mode 100644 index 00000000..f3647d7c --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/descriptor.rs @@ -0,0 +1,97 @@ +//! ref: composer/vendor/symfony/console/Descriptor/Descriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// @internal +pub trait Descriptor: DescriptorInterface { + fn output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>; + + fn set_output(&mut self, output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>); + + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: DescribableObject, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.set_output(output); + + // PHP dispatches via `$object instanceof ...`; the explicit `DescribableObject` enum makes + // that dispatch a `match`. + match object { + DescribableObject::InputArgument(argument) => { + self.describe_input_argument(&argument, options)?; + } + DescribableObject::InputOption(option) => { + self.describe_input_option(&option, options)?; + } + DescribableObject::InputDefinition(definition) => { + self.describe_input_definition(&definition, options)?; + } + DescribableObject::Command(command) => { + self.describe_command(&*command.borrow(), options)?; + } + DescribableObject::Application(application) => { + self.describe_application(application, options)?; + } + } + + Ok(()) + } + + /// Writes content to output. + fn write(&self, content: &str, decorated: bool) { + self.output().borrow().write( + &[content.to_string()], + false, + if decorated { + crate::output::output_interface::OUTPUT_NORMAL + } else { + crate::output::output_interface::OUTPUT_RAW + }, + ); + } + + /// Describes an InputArgument instance. + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; + + /// Describes an InputOption instance. + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; + + /// Describes an InputDefinition instance. + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; + + /// Describes a Command instance. + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; + + /// Describes an Application instance. + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; +} diff --git a/crates/shirabe-symfony-console/src/descriptor/descriptor_interface.rs b/crates/shirabe-symfony-console/src/descriptor/descriptor_interface.rs new file mode 100644 index 00000000..c50c6a99 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/descriptor_interface.rs @@ -0,0 +1,29 @@ +//! ref: composer/vendor/symfony/console/Descriptor/DescriptorInterface.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// The set of objects the descriptors know how to describe. +pub enum DescribableObject { + InputArgument(InputArgument), + InputOption(InputOption), + InputDefinition(InputDefinition), + Command(std::rc::Rc<std::cell::RefCell<dyn Command>>), + Application(std::rc::Rc<std::cell::RefCell<dyn Application>>), +} + +/// Descriptor interface. +pub trait DescriptorInterface { + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: DescribableObject, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; +} diff --git a/crates/shirabe-symfony-console/src/descriptor/json_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/json_descriptor.rs new file mode 100644 index 00000000..983d5f08 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/json_descriptor.rs @@ -0,0 +1,402 @@ +//! ref: composer/vendor/symfony/console/Descriptor/JsonDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_pcre::preg::Preg; +use shirabe_php_shim::{PhpMixed, php_regex}; + +/// JSON descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct JsonDescriptor { + output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, +} + +impl JsonDescriptor { + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.write_data(self.get_input_argument_data(argument)?, &options)?; + Ok(()) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.write_data(self.get_input_option_data(option, false)?, &options)?; + if option.is_negatable() { + self.write_data(self.get_input_option_data(option, true)?, &options)?; + } + Ok(()) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.write_data(self.get_input_definition_data(definition)?, &options)?; + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let short = matches!(options.get("short"), Some(PhpMixed::Bool(true))); + self.write_data(self.get_command_data(command, short)?, &options)?; + Ok(()) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let described_namespace = match options.get("namespace") { + Some(PhpMixed::String(s)) => Some(s.clone()), + _ => None, + }; + let mut description = + ApplicationDescription::new(application.clone(), described_namespace.clone(), true); + let mut commands: Vec<PhpMixed> = vec![]; + + let short = matches!(options.get("short"), Some(PhpMixed::Bool(true))); + for command in description.get_commands().values() { + let command = command.borrow(); + commands.push(PhpMixed::Array( + self.get_command_data(&*command, short)? + .into_iter() + .collect(), + )); + } + + let mut data: IndexMap<String, PhpMixed> = IndexMap::new(); + if "UNKNOWN" != application.borrow().get_name() { + let mut application_data: IndexMap<String, PhpMixed> = IndexMap::new(); + application_data.insert( + "name".to_string(), + PhpMixed::String(application.borrow().get_name()), + ); + if "UNKNOWN" != application.borrow().get_version() { + application_data.insert( + "version".to_string(), + PhpMixed::String(application.borrow().get_version()), + ); + } + data.insert("application".to_string(), PhpMixed::Array(application_data)); + } + + data.insert("commands".to_string(), PhpMixed::List(commands)); + + if let Some(described_namespace) = described_namespace { + data.insert( + "namespace".to_string(), + PhpMixed::String(described_namespace), + ); + } else { + data.insert( + "namespaces".to_string(), + PhpMixed::List( + description + .get_namespaces() + .into_values() + .map(|ns| PhpMixed::Array(ns.into_iter().collect())) + .collect(), + ), + ); + } + + self.write_data(data, &options)?; + Ok(()) + } + + /// Writes data as json. + fn write_data( + &self, + data: IndexMap<String, PhpMixed>, + options: &IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let flags = match options.get("json_encoding") { + Some(PhpMixed::Int(f)) => *f, + _ => 0, + }; + + self.write( + &shirabe_php_shim::json_encode_ex(&PhpMixed::Array(data.into_iter().collect()), flags) + .unwrap_or_default(), + false, + ); + Ok(()) + } + + fn get_input_argument_data( + &self, + argument: &InputArgument, + ) -> anyhow::Result<IndexMap<String, PhpMixed>> { + let mut data: IndexMap<String, PhpMixed> = IndexMap::new(); + data.insert( + "name".to_string(), + PhpMixed::String(argument.get_name().to_string()), + ); + data.insert( + "is_required".to_string(), + PhpMixed::Bool(argument.is_required()), + ); + data.insert("is_array".to_string(), PhpMixed::Bool(argument.is_array())); + data.insert( + "description".to_string(), + PhpMixed::String(Preg::replace( + php_regex!("/\\s*[\\r\\n]\\s*/"), + " ", + argument.get_description(), + )), + ); + data.insert( + "default".to_string(), + if matches!(argument.get_default(), PhpMixed::Float(f) if f.is_infinite() && *f > 0.0) { + PhpMixed::String("INF".to_string()) + } else { + argument.get_default().clone() + }, + ); + Ok(data) + } + + fn get_input_option_data( + &self, + option: &InputOption, + negated: bool, + ) -> anyhow::Result<IndexMap<String, PhpMixed>> { + let mut data: IndexMap<String, PhpMixed> = IndexMap::new(); + if negated { + data.insert( + "name".to_string(), + PhpMixed::String(format!("--no-{}", option.get_name())), + ); + data.insert("shortcut".to_string(), PhpMixed::String(String::new())); + data.insert("accept_value".to_string(), PhpMixed::Bool(false)); + data.insert("is_value_required".to_string(), PhpMixed::Bool(false)); + data.insert("is_multiple".to_string(), PhpMixed::Bool(false)); + data.insert( + "description".to_string(), + PhpMixed::String(format!("Negate the \"--{}\" option", option.get_name())), + ); + data.insert("default".to_string(), PhpMixed::Bool(false)); + } else { + data.insert( + "name".to_string(), + PhpMixed::String(format!("--{}", option.get_name())), + ); + data.insert( + "shortcut".to_string(), + PhpMixed::String(if let Some(shortcut) = option.get_shortcut() { + format!("-{}", shirabe_php_shim::str_replace("|", "|-", shortcut)) + } else { + String::new() + }), + ); + data.insert( + "accept_value".to_string(), + PhpMixed::Bool(option.accept_value()), + ); + data.insert( + "is_value_required".to_string(), + PhpMixed::Bool(option.is_value_required()), + ); + data.insert("is_multiple".to_string(), PhpMixed::Bool(option.is_array())); + data.insert( + "description".to_string(), + PhpMixed::String(Preg::replace( + php_regex!("/\\s*[\\r\\n]\\s*/"), + " ", + option.get_description(), + )), + ); + data.insert( + "default".to_string(), + if matches!(option.get_default(), PhpMixed::Float(f) if f.is_infinite() && *f > 0.0) + { + PhpMixed::String("INF".to_string()) + } else { + option.get_default().clone() + }, + ); + } + Ok(data) + } + + fn get_input_definition_data( + &self, + definition: &InputDefinition, + ) -> anyhow::Result<IndexMap<String, PhpMixed>> { + let mut input_arguments: IndexMap<String, PhpMixed> = IndexMap::new(); + for (name, argument) in definition.get_arguments() { + input_arguments.insert( + name.clone(), + PhpMixed::Array( + self.get_input_argument_data(argument)? + .into_iter() + .collect(), + ), + ); + } + + let mut input_options: IndexMap<String, PhpMixed> = IndexMap::new(); + for (name, option) in definition.get_options() { + input_options.insert( + name.clone(), + PhpMixed::Array( + self.get_input_option_data(option, false)? + .into_iter() + .collect(), + ), + ); + if option.is_negatable() { + input_options.insert( + format!("no-{}", name), + PhpMixed::Array( + self.get_input_option_data(option, true)? + .into_iter() + .collect(), + ), + ); + } + } + + let mut data: IndexMap<String, PhpMixed> = IndexMap::new(); + data.insert("arguments".to_string(), PhpMixed::Array(input_arguments)); + data.insert("options".to_string(), PhpMixed::Array(input_options)); + Ok(data) + } + + fn get_command_data( + &self, + command: &dyn Command, + short: bool, + ) -> anyhow::Result<IndexMap<String, PhpMixed>> { + let mut data: IndexMap<String, PhpMixed> = IndexMap::new(); + data.insert( + "name".to_string(), + match command.get_name() { + Some(name) => PhpMixed::String(name), + None => PhpMixed::Null, + }, + ); + data.insert( + "description".to_string(), + PhpMixed::String(command.get_description()), + ); + + if short { + data.insert( + "usage".to_string(), + PhpMixed::List( + command + .get_aliases() + .into_iter() + .map(PhpMixed::String) + .collect(), + ), + ); + } else { + command.merge_application_definition(false); + + let mut usage = vec![PhpMixed::String(command.get_synopsis(false))]; + usage.extend(command.get_usages().into_iter().map(PhpMixed::String)); + usage.extend(command.get_aliases().into_iter().map(PhpMixed::String)); + data.insert("usage".to_string(), PhpMixed::List(usage)); + data.insert( + "help".to_string(), + PhpMixed::String(command.get_processed_help()), + ); + data.insert( + "definition".to_string(), + PhpMixed::Array( + self.get_input_definition_data(&command.get_definition())? + .into_iter() + .collect(), + ), + ); + } + + data.insert("hidden".to_string(), PhpMixed::Bool(command.is_hidden())); + + Ok(data) + } +} + +impl DescriptorInterface for JsonDescriptor { + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: DescribableObject, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + Descriptor::describe(self, output, object, options) + } +} + +impl Descriptor for JsonDescriptor { + fn output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) { + self.output = Some(output); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/markdown_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/markdown_descriptor.rs new file mode 100644 index 00000000..b64c2be0 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/markdown_descriptor.rs @@ -0,0 +1,378 @@ +//! ref: composer/vendor/symfony/console/Descriptor/MarkdownDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::helper::helper::Helper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_pcre::preg::Preg; +use shirabe_php_shim::PhpMixed; + +/// Markdown descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct MarkdownDescriptor { + output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, +} + +impl MarkdownDescriptor { + pub fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: DescribableObject, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let decorated = output.borrow().is_decorated(); + output.borrow().set_decorated(false); + + Descriptor::describe(self, output.clone(), object, options)?; + + output.borrow().set_decorated(decorated); + Ok(()) + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + _options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let name = if !argument.get_name().is_empty() { + argument.get_name().to_string() + } else { + "<none>".to_string() + }; + self.write( + &format!( + "#### `{}`\n\n{}* Is required: {}\n* Is array: {}\n* Default: `{}`", + name, + if !argument.get_description().is_empty() { + format!( + "{}\n\n", + Preg::replace("/\\s*[\\r\\n]\\s*/", "\n", argument.get_description()) + ) + } else { + String::new() + }, + if argument.is_required() { "yes" } else { "no" }, + if argument.is_array() { "yes" } else { "no" }, + shirabe_php_shim::str_replace( + "\n", + "", + &shirabe_php_shim::var_export(argument.get_default(), true), + ), + ), + true, + ); + Ok(()) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + _options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let mut name = format!("--{}", option.get_name()); + if option.is_negatable() { + name += &format!("|--no-{}", option.get_name()); + } + if let Some(shortcut) = option.get_shortcut() { + name += &format!("|-{}", shirabe_php_shim::str_replace("|", "|-", shortcut)); + } + + self.write( + &format!( + "#### `{}`\n\n{}* Accept value: {}\n* Is value required: {}\n* Is multiple: {}\n* Is negatable: {}\n* Default: `{}`", + name, + if !option.get_description().is_empty() { + format!( + "{}\n\n", + Preg::replace("/\\s*[\\r\\n]\\s*/", "\n", option.get_description()) + ) + } else { + String::new() + }, + if option.accept_value() { "yes" } else { "no" }, + if option.is_value_required() { "yes" } else { "no" }, + if option.is_array() { "yes" } else { "no" }, + if option.is_negatable() { "yes" } else { "no" }, + shirabe_php_shim::str_replace( + "\n", + "", + &shirabe_php_shim::var_export(option.get_default(), true), + ), + ), + true, + ); + Ok(()) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + _options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let show_arguments = !definition.get_arguments().is_empty(); + if show_arguments { + self.write("### Arguments", true); + for argument in definition.get_arguments().values() { + self.write("\n\n", true); + // describeInputArgument returns null; the guarded write never runs. + self.describe_input_argument(argument, IndexMap::new())?; + } + } + + if !definition.get_options().is_empty() { + if show_arguments { + self.write("\n\n", true); + } + + self.write("### Options", true); + for option in definition.get_options().values() { + self.write("\n\n", true); + // describeInputOption returns null; the guarded write never runs. + self.describe_input_option(option, IndexMap::new())?; + } + } + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + if matches!(options.get("short"), Some(PhpMixed::Bool(true))) { + self.write( + &format!( + "`{}`\n{}\n\n{}### Usage\n\n{}", + command.get_name().unwrap_or_default(), + shirabe_php_shim::str_repeat( + "-", + (Helper::width(command.get_name().as_deref().unwrap_or("")) + 2) as usize + ), + if !command.get_description().is_empty() { + format!("{}\n\n", command.get_description()) + } else { + String::new() + }, + command + .get_aliases() + .iter() + .fold(String::new(), |carry, usage| { + format!("{}* `{}`\n", carry, usage) + }), + ), + true, + ); + + return Ok(()); + } + + command.merge_application_definition(false); + + let mut usages = vec![command.get_synopsis(false)]; + usages.extend(command.get_aliases()); + usages.extend(command.get_usages()); + self.write( + &format!( + "`{}`\n{}\n\n{}### Usage\n\n{}", + command.get_name().unwrap_or_default(), + shirabe_php_shim::str_repeat( + "-", + (Helper::width(command.get_name().as_deref().unwrap_or("")) + 2) as usize + ), + if !command.get_description().is_empty() { + format!("{}\n\n", command.get_description()) + } else { + String::new() + }, + usages.iter().fold(String::new(), |carry, usage| { + format!("{}* `{}`\n", carry, usage) + }), + ), + true, + ); + + let help = command.get_processed_help(); + if !help.is_empty() { + self.write("\n", true); + self.write(&help, true); + } + + let definition = command.get_definition().clone(); + if !definition.get_options().is_empty() || !definition.get_arguments().is_empty() { + self.write("\n\n", true); + self.describe_input_definition(&definition, IndexMap::new())?; + } + Ok(()) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let described_namespace = match options.get("namespace") { + Some(PhpMixed::String(s)) => Some(s.clone()), + _ => None, + }; + let mut description = + ApplicationDescription::new(application.clone(), described_namespace, false); + let title = self.get_application_title(&*application.borrow()); + + self.write( + &format!( + "{}\n{}", + title, + shirabe_php_shim::str_repeat("=", Helper::width(&title) as usize) + ), + true, + ); + + for namespace in description.get_namespaces().values() { + let namespace_id = match namespace.get("id") { + Some(PhpMixed::String(s)) => s.clone(), + _ => String::new(), + }; + if ApplicationDescription::GLOBAL_NAMESPACE != namespace_id { + self.write("\n\n", true); + self.write(&format!("**{}:**", namespace_id), true); + } + + self.write("\n\n", true); + let command_names: Vec<String> = match namespace.get("commands") { + Some(PhpMixed::List(names)) => names + .iter() + .filter_map(|n| n.as_string().map(|s| s.to_string())) + .collect(), + _ => vec![], + }; + self.write( + &command_names + .iter() + .map(|command_name| { + Ok(format!( + "* [`{}`](#{})", + command_name.clone(), + shirabe_php_shim::str_replace( + ":", + "", + &description + .get_command(command_name)? + .borrow() + .get_name() + .unwrap_or_default(), + ), + )) + }) + .collect::<anyhow::Result<Vec<String>>>()? + .join("\n"), + true, + ); + } + + for command in description.get_commands().values() { + let command = command.borrow(); + self.write("\n\n", true); + // describeCommand returns null; the guarded write never runs. + self.describe_command(&*command, options.clone())?; + } + Ok(()) + } + + fn get_application_title(&self, application: &dyn Application) -> String { + if "UNKNOWN" != application.get_name() { + if "UNKNOWN" != application.get_version() { + return format!("{} {}", application.get_name(), application.get_version()); + } + + return application.get_name(); + } + + "Console Tool".to_string() + } +} + +impl DescriptorInterface for MarkdownDescriptor { + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: DescribableObject, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe(self, output, object, options) + } +} + +impl Descriptor for MarkdownDescriptor { + fn output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) { + self.output = Some(output); + } + + /// {@inheritdoc} + fn write(&self, content: &str, decorated: bool) { + // PHP overrides write() only to flip the default of $decorated to true; + // it still delegates to parent::write. + let _ = decorated; + self.output().borrow().write( + &[content.to_string()], + false, + if decorated { + crate::output::output_interface::OUTPUT_NORMAL + } else { + crate::output::output_interface::OUTPUT_RAW + }, + ); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/text_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/text_descriptor.rs new file mode 100644 index 00000000..3ea09355 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/text_descriptor.rs @@ -0,0 +1,592 @@ +//! ref: composer/vendor/symfony/console/Descriptor/TextDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::formatter::output_formatter::OutputFormatter; +use crate::helper::helper::Helper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_pcre::preg::Preg; +use shirabe_php_shim::PhpMixed; + +/// Text descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct TextDescriptor { + output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, +} + +/// Models PHP's `array<Command|string>` passed to `getColumnWidth`. +/// `PhpMixed` cannot hold console types, so a dedicated enum is used. +#[derive(Debug)] +enum CommandOrString { + Command(std::rc::Rc<std::cell::RefCell<dyn Command>>), + String(String), +} + +impl TextDescriptor { + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let default = if !argument.get_default().is_null() + && (!matches!( + argument.get_default(), + PhpMixed::List(_) | PhpMixed::Array(_) + ) || shirabe_php_shim::count(argument.get_default()) != 0) + { + format!( + "<comment> [default: {}]</comment>", + self.format_default_value(argument.get_default())? + ) + } else { + String::new() + }; + + let total_width = match options.get("total_width") { + Some(PhpMixed::Int(w)) => *w, + _ => Helper::width(argument.get_name()), + }; + let spacing_width = total_width - shirabe_php_shim::strlen(argument.get_name()); + + self.write_text( + &format!( + " <info>{}</info> {}{}{}", + argument.get_name(), + shirabe_php_shim::str_repeat(" ", spacing_width as usize), + // + 4 = 2 spaces before <info>, 2 spaces after </info> + Preg::replace( + "/\\s*[\\r\\n]\\s*/", + &format!( + "\n{}", + shirabe_php_shim::str_repeat(" ", (total_width + 4) as usize) + ), + argument.get_description(), + ), + default, + ), + &options, + ); + Ok(()) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let default = if option.accept_value() + && !option.get_default().is_null() + && (!matches!(option.get_default(), PhpMixed::List(_) | PhpMixed::Array(_)) + || shirabe_php_shim::count(option.get_default()) != 0) + { + format!( + "<comment> [default: {}]</comment>", + self.format_default_value(option.get_default())? + ) + } else { + String::new() + }; + + let mut value = String::new(); + if option.accept_value() { + value = format!("={}", shirabe_php_shim::strtoupper(option.get_name())); + + if option.is_value_optional() { + value = format!("[{}]", value); + } + } + + let total_width = match options.get("total_width") { + Some(PhpMixed::Int(w)) => *w, + _ => self.calculate_total_width_for_options(&[option]), + }; + let synopsis = format!( + "{}{}", + if option.get_shortcut().is_some() { + format!("-{}, ", option.get_shortcut().unwrap()) + } else { + " ".to_string() + }, + if option.is_negatable() { + format!("--{0}|--no-{0}", option.get_name().to_string()) + } else { + format!("--{0}{1}", option.get_name(), value) + } + ); + + let spacing_width = total_width - Helper::width(&synopsis); + + self.write_text( + &format!( + " <info>{}</info> {}{}{}{}", + synopsis, + shirabe_php_shim::str_repeat(" ", spacing_width as usize), + // + 4 = 2 spaces before <info>, 2 spaces after </info> + Preg::replace( + "/\\s*[\\r\\n]\\s*/", + &format!( + "\n{}", + shirabe_php_shim::str_repeat(" ", (total_width + 4) as usize) + ), + option.get_description(), + ), + default, + if option.is_array() { + "<comment> (multiple values allowed)</comment>".to_string() + } else { + String::new() + }, + ), + &options, + ); + Ok(()) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let mut total_width = self.calculate_total_width_for_options( + &definition + .get_options() + .values() + .map(|o| o.as_ref()) + .collect::<Vec<_>>(), + ); + for argument in definition.get_arguments().values() { + total_width = std::cmp::max(total_width, Helper::width(argument.get_name())); + } + + if !definition.get_arguments().is_empty() { + self.write_text("<comment>Arguments:</comment>", &options); + self.write_text("\n", &IndexMap::new()); + for argument in definition.get_arguments().values() { + let mut merged = options.clone(); + merged.insert("total_width".to_string(), PhpMixed::Int(total_width)); + self.describe_input_argument(argument, merged)?; + self.write_text("\n", &IndexMap::new()); + } + } + + if !definition.get_arguments().is_empty() && !definition.get_options().is_empty() { + self.write_text("\n", &IndexMap::new()); + } + + if !definition.get_options().is_empty() { + let mut later_options: Vec<&InputOption> = vec![]; + + self.write_text("<comment>Options:</comment>", &options); + for option in definition.get_options().values() { + if shirabe_php_shim::strlen(option.get_shortcut().unwrap_or("")) > 1 { + later_options.push(option.as_ref()); + continue; + } + self.write_text("\n", &IndexMap::new()); + let mut merged = options.clone(); + merged.insert("total_width".to_string(), PhpMixed::Int(total_width)); + self.describe_input_option(option, merged)?; + } + for option in later_options { + self.write_text("\n", &IndexMap::new()); + let mut merged = options.clone(); + merged.insert("total_width".to_string(), PhpMixed::Int(total_width)); + self.describe_input_option(option, merged)?; + } + } + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + command.merge_application_definition(false); + + let description = command.get_description(); + if !description.is_empty() { + self.write_text("<comment>Description:</comment>", &options); + self.write_text("\n", &IndexMap::new()); + self.write_text(&format!(" {}", description), &IndexMap::new()); + self.write_text("\n\n", &IndexMap::new()); + } + + self.write_text("<comment>Usage:</comment>", &options); + let mut usages = vec![command.get_synopsis(true)]; + usages.extend(command.get_aliases()); + usages.extend(command.get_usages()); + for usage in usages { + self.write_text("\n", &IndexMap::new()); + self.write_text(&format!(" {}", OutputFormatter::escape(&usage)?), &options); + } + self.write_text("\n", &IndexMap::new()); + + let definition = command.get_definition().clone(); + if !definition.get_options().is_empty() || !definition.get_arguments().is_empty() { + self.write_text("\n", &IndexMap::new()); + self.describe_input_definition(&definition, options.clone())?; + self.write_text("\n", &IndexMap::new()); + } + + let help = command.get_processed_help(); + if !help.is_empty() && help != description { + self.write_text("\n", &IndexMap::new()); + self.write_text("<comment>Help:</comment>", &options); + self.write_text("\n", &IndexMap::new()); + self.write_text( + &format!(" {}", shirabe_php_shim::str_replace("\n", "\n ", &help)), + &options, + ); + self.write_text("\n", &IndexMap::new()); + } + Ok(()) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let described_namespace = match options.get("namespace") { + Some(PhpMixed::String(s)) => Some(s.clone()), + _ => None, + }; + let mut description = + ApplicationDescription::new(application.clone(), described_namespace.clone(), false); + + if matches!(options.get("raw_text"), Some(v) if shirabe_php_shim::php_truthy(v)) { + let width = self.get_column_width( + &description + .get_commands() + .values() + .map(|c| CommandOrString::Command(c.clone())) + .collect::<Vec<_>>(), + ); + + let command_list: Vec<_> = description.get_commands().values().cloned().collect(); + for command in &command_list { + let command = command.borrow(); + self.write_text( + &format!( + "{:<w$} {}", + command.get_name().unwrap_or_default(), + command.get_description(), + w = width as usize, + ), + &options, + ); + self.write_text("\n", &IndexMap::new()); + } + } else { + let help = application.borrow().get_help(); + if !help.is_empty() { + self.write_text(&format!("{}\n\n", help), &options); + } + + self.write_text("<comment>Usage:</comment>\n", &options); + self.write_text(" command [options] [arguments]\n\n", &options); + + let app_definition = application.borrow_mut().get_definition(); + let options_only: Vec<std::rc::Rc<InputOption>> = app_definition + .borrow() + .get_options() + .values() + .cloned() + .collect(); + let definition = InputDefinition::from_options(options_only)?; + self.describe_input_definition(&definition, options.clone())?; + + self.write_text("\n", &IndexMap::new()); + self.write_text("\n", &IndexMap::new()); + + let mut commands = description.get_commands().clone(); + let namespaces = description.get_namespaces(); + if described_namespace.is_some() && !namespaces.is_empty() { + // make sure all alias commands are included when describing a specific namespace + let described_namespace_info = namespaces.values().next().unwrap(); + if let Some(PhpMixed::List(names)) = described_namespace_info.get("commands") { + let names: Vec<String> = names + .iter() + .filter_map(|n| n.as_string().map(|s| s.to_string())) + .collect(); + for name in names { + let command = description.get_command(&name)?; + commands.insert(name, command); + } + } + } + + // calculate max. width based on available commands per namespace + let width = self.get_column_width(&{ + let command_keys: Vec<String> = commands.keys().cloned().collect(); + let mut merged: Vec<CommandOrString> = vec![]; + for namespace in namespaces.values() { + if let Some(PhpMixed::List(ns_commands)) = namespace.get("commands") { + for c in ns_commands { + if let PhpMixed::String(name) = c + && command_keys.contains(name) + { + merged.push(CommandOrString::String(name.clone())); + } + } + } + } + merged + }); + + if let Some(ref described_namespace) = described_namespace { + self.write_text( + &format!( + "<comment>Available commands for the \"{}\" namespace:</comment>", + described_namespace.clone(), + ), + &options, + ); + } else { + self.write_text("<comment>Available commands:</comment>", &options); + } + + for namespace in namespaces.values() { + let ns_commands: Vec<String> = match namespace.get("commands") { + Some(PhpMixed::List(names)) => names + .iter() + .filter_map(|n| match n { + PhpMixed::String(name) if commands.contains_key(name) => { + Some(name.clone()) + } + _ => None, + }) + .collect(), + _ => vec![], + }; + + if ns_commands.is_empty() { + continue; + } + + let namespace_id = match namespace.get("id") { + Some(PhpMixed::String(s)) => s.clone(), + _ => String::new(), + }; + + if described_namespace.is_none() + && ApplicationDescription::GLOBAL_NAMESPACE != namespace_id + { + self.write_text("\n", &IndexMap::new()); + self.write_text(&format!(" <comment>{}</comment>", namespace_id), &options); + } + + for name in ns_commands { + self.write_text("\n", &IndexMap::new()); + let spacing_width = width - Helper::width(&name); + let command = commands.get(&name).unwrap().clone(); + let command = command.borrow(); + let command_aliases = if command.get_name().as_deref() == Some(name.as_str()) { + self.get_command_aliases_text(&*command) + } else { + String::new() + }; + self.write_text( + &format!( + " <info>{}</info>{}{}{}", + name.clone(), + shirabe_php_shim::str_repeat(" ", spacing_width as usize), + command_aliases, + command.get_description(), + ), + &options, + ); + } + } + + self.write_text("\n", &IndexMap::new()); + } + Ok(()) + } + + fn write_text(&self, content: &str, options: &IndexMap<String, PhpMixed>) { + let raw_text = + matches!(options.get("raw_text"), Some(v) if shirabe_php_shim::php_truthy(v)); + let content = if raw_text { + shirabe_php_shim::strip_tags(content) + } else { + content.to_string() + }; + let decorated = match options.get("raw_output") { + Some(v) => !shirabe_php_shim::php_truthy(v), + None => true, + }; + self.write(&content, decorated); + } + + /// Formats command aliases to show them in the command description. + fn get_command_aliases_text(&self, command: &dyn Command) -> String { + let mut text = String::new(); + let aliases = command.get_aliases(); + + if !aliases.is_empty() { + text = format!("[{}] ", aliases.join("|")); + } + + text + } + + /// Formats input option/argument default value. + fn format_default_value(&self, default: &PhpMixed) -> anyhow::Result<String> { + if matches!(default, PhpMixed::Float(f) if f.is_infinite() && *f > 0.0) { + return Ok("INF".to_string()); + } + + let default = match default { + PhpMixed::String(s) => PhpMixed::String(OutputFormatter::escape(s)?), + PhpMixed::Array(arr) => { + let mut arr = arr.clone(); + for (_key, value) in arr.iter_mut() { + if let PhpMixed::String(s) = &*value { + *value = PhpMixed::String(OutputFormatter::escape(s)?); + } + } + PhpMixed::Array(arr) + } + PhpMixed::List(list) => { + let mut list = list.clone(); + for value in list.iter_mut() { + if let PhpMixed::String(s) = &*value { + *value = PhpMixed::String(OutputFormatter::escape(s)?); + } + } + PhpMixed::List(list) + } + other => other.clone(), + }; + + Ok(shirabe_php_shim::str_replace( + "\\\\", + "\\", + &shirabe_php_shim::json_encode_ex( + &default, + shirabe_php_shim::JSON_UNESCAPED_SLASHES | shirabe_php_shim::JSON_UNESCAPED_UNICODE, + ) + .unwrap_or_default(), + )) + } + + fn get_column_width(&self, commands: &[CommandOrString]) -> i64 { + let mut widths: Vec<i64> = vec![]; + + for command in commands { + // case $command instanceof Command + match command { + CommandOrString::Command(command) => { + let command = command.borrow(); + widths.push(Helper::width(command.get_name().as_deref().unwrap_or(""))); + for alias in command.get_aliases() { + widths.push(Helper::width(&alias)); + } + } + CommandOrString::String(s) => { + widths.push(Helper::width(s)); + } + } + } + + if !widths.is_empty() { + widths.into_iter().max().unwrap() + 2 + } else { + 0 + } + } + + fn calculate_total_width_for_options(&self, options: &[&InputOption]) -> i64 { + let mut total_width: i64 = 0; + for option in options { + // "-" + shortcut + ", --" + name + let mut name_length = 1 + + Helper::width(option.get_shortcut().unwrap_or("")).max(1) + + 4 + + Helper::width(option.get_name()); + if option.is_negatable() { + name_length += 6 + Helper::width(option.get_name()); // |--no- + name + } else if option.accept_value() { + let mut value_length = 1 + Helper::width(option.get_name()); // = + value + value_length += if option.is_value_optional() { 2 } else { 0 }; // [ + ] + + name_length += value_length; + } + total_width = std::cmp::max(total_width, name_length); + } + + total_width + } +} + +impl DescriptorInterface for TextDescriptor { + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: DescribableObject, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + Descriptor::describe(self, output, object, options) + } +} + +impl Descriptor for TextDescriptor { + fn output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) { + self.output = Some(output); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + TextDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + TextDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + TextDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + TextDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + TextDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/xml_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/xml_descriptor.rs new file mode 100644 index 00000000..11c26b46 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/xml_descriptor.rs @@ -0,0 +1,403 @@ +//! ref: composer/vendor/symfony/console/Descriptor/XmlDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::{DOMDocument, DOMNode, PhpMixed}; + +/// XML descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct XmlDescriptor { + output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, +} + +impl XmlDescriptor { + pub fn get_input_definition_document(&self, definition: &InputDefinition) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + let definition_xml = dom.append_child(dom.create_element("definition")); + + let arguments_xml = definition_xml.append_child(dom.create_element("arguments")); + for argument in definition.get_arguments().values() { + let argument_xml = self.get_input_argument_document(argument); + self.append_document(&arguments_xml, &argument_xml.as_node()); + } + + let options_xml = definition_xml.append_child(dom.create_element("options")); + for option in definition.get_options().values() { + let option_xml = self.get_input_option_document(option); + self.append_document(&options_xml, &option_xml.as_node()); + } + + dom + } + + pub fn get_command_document(&self, command: &dyn Command, short: bool) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + let command_xml = dom.append_child(dom.create_element("command")); + + let name = command.get_name().unwrap_or_default(); + command_xml.set_attribute("id", &name); + command_xml.set_attribute("name", &name); + command_xml.set_attribute("hidden", if command.is_hidden() { "1" } else { "0" }); + + let usages_xml = command_xml.append_child(dom.create_element("usages")); + + let description_xml = command_xml.append_child(dom.create_element("description")); + description_xml.append_child(dom.create_text_node(&shirabe_php_shim::str_replace( + "\n", + "\n ", + &command.get_description(), + ))); + + if short { + for usage in command.get_aliases() { + usages_xml.append_child(dom.create_element_with_value("usage", &usage)); + } + } else { + command.merge_application_definition(false); + + let mut usages = vec![command.get_synopsis(false)]; + usages.extend(command.get_aliases()); + usages.extend(command.get_usages()); + for usage in usages { + usages_xml.append_child(dom.create_element_with_value("usage", &usage)); + } + + let help_xml = command_xml.append_child(dom.create_element("help")); + help_xml.append_child(dom.create_text_node(&shirabe_php_shim::str_replace( + "\n", + "\n ", + &command.get_processed_help(), + ))); + + let command_definition = command.get_definition().clone(); + let definition_xml = self.get_input_definition_document(&command_definition); + let definition_node = definition_xml + .get_elements_by_tag_name("definition") + .item(0) + .expect("input definition document always contains a <definition> element"); + self.append_document(&command_xml, &definition_node); + } + + dom + } + + pub fn get_application_document( + &self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + namespace: Option<String>, + short: bool, + ) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + let root_xml = dom.append_child(dom.create_element("symfony")); + + let app_name = application.borrow().get_name(); + if app_name != "UNKNOWN" { + root_xml.set_attribute("name", &app_name); + let app_version = application.borrow().get_version(); + if app_version != "UNKNOWN" { + root_xml.set_attribute("version", &app_version); + } + } + + let commands_xml = root_xml.append_child(dom.create_element("commands")); + + let mut description = + ApplicationDescription::new(application.clone(), namespace.clone(), true); + + if let Some(ref namespace) = namespace { + commands_xml.set_attribute("namespace", namespace); + } + + for command in description.get_commands().values() { + let command = command.borrow(); + let command_xml = self.get_command_document(&*command, short); + self.append_document(&commands_xml, &command_xml.as_node()); + } + + if namespace.is_none() { + let namespaces_xml = root_xml.append_child(dom.create_element("namespaces")); + + let namespaces = description.get_namespaces(); + for namespace_description in namespaces.values() { + let namespace_array_xml = + namespaces_xml.append_child(dom.create_element("namespace")); + let id = match namespace_description.get("id") { + Some(PhpMixed::String(s)) => s.as_str(), + _ => "", + }; + namespace_array_xml.set_attribute("id", id); + + if let Some(PhpMixed::List(names)) = namespace_description.get("commands") { + for name in names { + let command_xml = + namespace_array_xml.append_child(dom.create_element("command")); + command_xml + .append_child(dom.create_text_node(name.as_string().unwrap_or(""))); + } + } + } + } + + dom + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + _options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.write_document(self.get_input_argument_document(argument)); + Ok(()) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + _options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.write_document(self.get_input_option_document(option)); + Ok(()) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + _options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.write_document(self.get_input_definition_document(definition)); + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let short = matches!(options.get("short"), Some(PhpMixed::Bool(true))); + self.write_document(self.get_command_document(command, short)); + Ok(()) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + let namespace = match options.get("namespace") { + Some(PhpMixed::String(s)) => Some(s.clone()), + _ => None, + }; + let short = matches!(options.get("short"), Some(PhpMixed::Bool(true))); + self.write_document(self.get_application_document(application, namespace, short)); + Ok(()) + } + + /// Appends document children to parent node. + fn append_document(&self, parent_node: &DOMNode, imported_parent: &DOMNode) { + for child_node in imported_parent.child_nodes() { + parent_node.append_child(parent_node.owner_document().import_node(&child_node, true)); + } + } + + /// Writes DOM document. + fn write_document(&self, dom: DOMDocument) { + dom.set_format_output(true); + let mut buf = Vec::new(); + dom.save_xml(&mut buf) + .expect("serializing XML to an in-memory buffer cannot fail"); + let xml = String::from_utf8(buf).expect("DOM serialization yields valid UTF-8"); + self.write(&xml, false); + } + + fn get_input_argument_document(&self, argument: &InputArgument) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + + let object_xml = dom.append_child(dom.create_element("argument")); + object_xml.set_attribute("name", argument.get_name()); + object_xml.set_attribute( + "is_required", + if argument.is_required() { "1" } else { "0" }, + ); + object_xml.set_attribute("is_array", if argument.is_array() { "1" } else { "0" }); + let description_xml = object_xml.append_child(dom.create_element("description")); + description_xml.append_child(dom.create_text_node(argument.get_description())); + + let defaults_xml = object_xml.append_child(dom.create_element("defaults")); + let defaults: Vec<String> = match argument.get_default() { + PhpMixed::List(_) | PhpMixed::Array(_) => { + self.default_values_as_strings(argument.get_default()) + } + PhpMixed::Bool(_) => vec![shirabe_php_shim::var_export(argument.get_default(), true)], + d if shirabe_php_shim::php_truthy(d) => { + vec![shirabe_php_shim::php_to_string(argument.get_default())] + } + _ => vec![], + }; + for default in defaults { + let default_xml = defaults_xml.append_child(dom.create_element("default")); + default_xml.append_child(dom.create_text_node(&default)); + } + + dom + } + + fn get_input_option_document(&self, option: &InputOption) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + + let object_xml = dom.append_child(dom.create_element("option")); + object_xml.set_attribute("name", &format!("--{}", option.get_name())); + let pos = shirabe_php_shim::strpos(option.get_shortcut().unwrap_or(""), "|"); + if let Some(pos) = pos { + object_xml.set_attribute( + "shortcut", + &format!( + "-{}", + shirabe_php_shim::substr(option.get_shortcut().unwrap(), 0, Some(pos as i64)) + ), + ); + object_xml.set_attribute( + "shortcuts", + &format!( + "-{}", + shirabe_php_shim::str_replace("|", "|-", option.get_shortcut().unwrap()) + ), + ); + } else { + object_xml.set_attribute( + "shortcut", + &match option.get_shortcut() { + Some(s) => format!("-{}", s), + None => String::new(), + }, + ); + } + object_xml.set_attribute( + "accept_value", + if option.accept_value() { "1" } else { "0" }, + ); + object_xml.set_attribute( + "is_value_required", + if option.is_value_required() { "1" } else { "0" }, + ); + object_xml.set_attribute("is_multiple", if option.is_array() { "1" } else { "0" }); + let description_xml = object_xml.append_child(dom.create_element("description")); + description_xml.append_child(dom.create_text_node(option.get_description())); + + if option.accept_value() { + let defaults: Vec<String> = match option.get_default() { + PhpMixed::List(_) | PhpMixed::Array(_) => { + self.default_values_as_strings(option.get_default()) + } + PhpMixed::Bool(_) => vec![shirabe_php_shim::var_export(option.get_default(), true)], + d if shirabe_php_shim::php_truthy(d) => { + vec![shirabe_php_shim::php_to_string(option.get_default())] + } + _ => vec![], + }; + let defaults_xml = object_xml.append_child(dom.create_element("defaults")); + + if !defaults.is_empty() { + for default in defaults { + let default_xml = defaults_xml.append_child(dom.create_element("default")); + default_xml.append_child(dom.create_text_node(&default)); + } + } + } + + if option.is_negatable() { + let object_xml = dom.append_child(dom.create_element("option")); + object_xml.set_attribute("name", &format!("--no-{}", option.get_name())); + object_xml.set_attribute("shortcut", ""); + object_xml.set_attribute("accept_value", "0"); + object_xml.set_attribute("is_value_required", "0"); + object_xml.set_attribute("is_multiple", "0"); + let description_xml = object_xml.append_child(dom.create_element("description")); + description_xml.append_child( + dom.create_text_node(&format!("Negate the \"--{}\" option", option.get_name())), + ); + } + + dom + } + + /// Helper used by the default-value branches of getInputArgumentDocument / + /// getInputOptionDocument when the default is an array (returns it verbatim). + fn default_values_as_strings(&self, default: &PhpMixed) -> Vec<String> { + match default { + PhpMixed::List(list) => list.iter().map(shirabe_php_shim::php_to_string).collect(), + PhpMixed::Array(arr) => arr.values().map(shirabe_php_shim::php_to_string).collect(), + _ => vec![], + } + } +} + +impl DescriptorInterface for XmlDescriptor { + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: DescribableObject, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + Descriptor::describe(self, output, object, options) + } +} + +impl Descriptor for XmlDescriptor { + fn output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) { + self.output = Some(output); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<dyn Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_application(self, application, options) + } +} |
