diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-12 01:01:35 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-12 01:01:43 +0900 |
| commit | 1e44f5723e4c0e0903d00a61f254901e612fe5e1 (patch) | |
| tree | 9c2e1eec364bbf6418a4072ee7767b04c3df0297 /crates/shirabe-external-packages/src/symfony/console/descriptor | |
| parent | ddf0a624145b618c05c2ee47414545b99b685f37 (diff) | |
| download | php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.gz php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.zst php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.zip | |
feat(symfony-console): port Symfony Console and make the workspace compile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-external-packages/src/symfony/console/descriptor')
8 files changed, 2191 insertions, 0 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/descriptor/application_description.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/application_description.rs new file mode 100644 index 0000000..3454b78 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/application_description.rs @@ -0,0 +1,185 @@ +use crate::symfony::console::application::Application; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::exception::command_not_found_exception::CommandNotFoundException; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; + +/// @internal +#[derive(Debug)] +pub struct ApplicationDescription { + application: Rc<RefCell<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, Rc<RefCell<dyn Command>>>>, + + /// @var array<string, Command> + aliases: Option<IndexMap<String, Rc<RefCell<dyn Command>>>>, +} + +impl ApplicationDescription { + pub const GLOBAL_NAMESPACE: &'static str = "_global"; + + pub fn new( + application: Rc<RefCell<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() + } + + /// @return Command[] + pub fn get_commands(&mut self) -> &IndexMap<String, Rc<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<Rc<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(|n| Box::new(PhpMixed::String(n))) + .collect(), + ), + ); + self.namespaces.as_mut().unwrap().insert(namespace, entry); + } + } + + fn sort_commands( + &self, + commands: IndexMap<String, Rc<RefCell<dyn Command>>>, + ) -> IndexMap<String, IndexMap<String, Rc<RefCell<dyn Command>>>> { + let mut namespaced_commands: IndexMap<String, IndexMap<String, Rc<RefCell<dyn Command>>>> = + IndexMap::new(); + let mut global_commands: IndexMap<String, Rc<RefCell<dyn Command>>> = IndexMap::new(); + let mut sorted_commands: IndexMap<String, IndexMap<String, Rc<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_insert_with(IndexMap::new) + .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-external-packages/src/symfony/console/descriptor/descriptor.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/descriptor.rs new file mode 100644 index 0000000..d4aa575 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/descriptor.rs @@ -0,0 +1,119 @@ +use crate::symfony::console::application::Application; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::descriptor::descriptor_interface::DescriptorInterface; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::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: PhpMixed, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + self.set_output(output); + + // PHP dispatches via `$object instanceof ...`. The concrete runtime type of + // `object` must be recovered to route to the correct describe* method. + match true { + // case $object instanceof InputArgument: + _ if todo!("$object instanceof InputArgument") => { + let argument: InputArgument = todo!("downcast object to InputArgument"); + self.describe_input_argument(&argument, options)?; + } + // case $object instanceof InputOption: + _ if todo!("$object instanceof InputOption") => { + let option: InputOption = todo!("downcast object to InputOption"); + self.describe_input_option(&option, options)?; + } + // case $object instanceof InputDefinition: + _ if todo!("$object instanceof InputDefinition") => { + let definition: InputDefinition = todo!("downcast object to InputDefinition"); + self.describe_input_definition(&definition, options)?; + } + // case $object instanceof Command: + _ if todo!("$object instanceof Command") => { + let mut command: Box<dyn Command> = todo!("downcast object to Command"); + self.describe_command(command.as_mut(), options)?; + } + // case $object instanceof Application: + _ if todo!("$object instanceof Application") => { + let application: std::rc::Rc<std::cell::RefCell<Application>> = + todo!("downcast object to Application"); + self.describe_application(application, options)?; + } + _ => { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: format!( + "Object of type \"{}\" is not describable.", + shirabe_php_shim::get_debug_type(&object) + ), + code: 0, + }) + .into(), + ); + } + } + + Ok(()) + } + + /// Writes content to output. + fn write(&self, content: &str, decorated: bool) { + self.output().borrow().write( + &[content.to_string()], + false, + if decorated { + crate::symfony::console::output::output_interface::OUTPUT_NORMAL + } else { + crate::symfony::console::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: &mut dyn Command, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; + + /// Describes an Application instance. + fn describe_application( + &mut self, + application: std::rc::Rc<std::cell::RefCell<Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; +} diff --git a/crates/shirabe-external-packages/src/symfony/console/descriptor/descriptor_interface.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/descriptor_interface.rs new file mode 100644 index 0000000..9420267 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/descriptor_interface.rs @@ -0,0 +1,13 @@ +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// Descriptor interface. +pub trait DescriptorInterface { + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: PhpMixed, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()>; +} diff --git a/crates/shirabe-external-packages/src/symfony/console/descriptor/json_descriptor.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/json_descriptor.rs new file mode 100644 index 0000000..b651618 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/json_descriptor.rs @@ -0,0 +1,426 @@ +use crate::composer::pcre::preg::Preg; +use crate::symfony::console::application::Application; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::descriptor::application_description::ApplicationDescription; +use crate::symfony::console::descriptor::descriptor::Descriptor; +use crate::symfony::console::descriptor::descriptor_interface::DescriptorInterface; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// 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: &mut 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<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<Box<PhpMixed>> = vec![]; + + let short = matches!(options.get("short"), Some(PhpMixed::Bool(true))); + let command_list: Vec<_> = description + .get_commands() + .values() + .map(|c| c.borrow().clone_box()) + .collect(); + for mut command in command_list { + commands.push(Box::new(PhpMixed::Array( + self.get_command_data(command.as_mut(), short)? + .into_iter() + .map(|(k, v)| (k, Box::new(v))) + .collect(), + ))); + } + + let mut data: IndexMap<String, PhpMixed> = IndexMap::new(); + if "UNKNOWN" != application.borrow().get_name() { + let mut application_data: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + application_data.insert( + "name".to_string(), + Box::new(PhpMixed::String(application.borrow().get_name())), + ); + if "UNKNOWN" != application.borrow().get_version() { + application_data.insert( + "version".to_string(), + Box::new(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| { + Box::new(PhpMixed::Array( + ns.into_iter().map(|(k, v)| (k, Box::new(v))).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().map(|(k, v)| (k, Box::new(v))).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( + "/\\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( + "/\\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, Box<PhpMixed>> = IndexMap::new(); + for (name, argument) in definition.get_arguments() { + input_arguments.insert( + name.clone(), + Box::new(PhpMixed::Array( + self.get_input_argument_data(argument)? + .into_iter() + .map(|(k, v)| (k, Box::new(v))) + .collect(), + )), + ); + } + + let mut input_options: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + for (name, option) in definition.get_options() { + input_options.insert( + name.clone(), + Box::new(PhpMixed::Array( + self.get_input_option_data(option, false)? + .into_iter() + .map(|(k, v)| (k, Box::new(v))) + .collect(), + )), + ); + if option.is_negatable() { + input_options.insert( + format!("no-{}", name), + Box::new(PhpMixed::Array( + self.get_input_option_data(option, true)? + .into_iter() + .map(|(k, v)| (k, Box::new(v))) + .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: &mut 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(|a| Box::new(PhpMixed::String(a))) + .collect(), + ), + ); + } else { + command.merge_application_definition(false); + + let mut usage = vec![Box::new(PhpMixed::String(command.get_synopsis(false)))]; + usage.extend( + command + .get_usages() + .into_iter() + .map(|u| Box::new(PhpMixed::String(u))), + ); + usage.extend( + command + .get_aliases() + .into_iter() + .map(|a| Box::new(PhpMixed::String(a))), + ); + 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() + .map(|(k, v)| (k, Box::new(v))) + .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: PhpMixed, + 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: &mut 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<Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/descriptor/markdown_descriptor.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/markdown_descriptor.rs new file mode 100644 index 0000000..5ec5c95 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/markdown_descriptor.rs @@ -0,0 +1,388 @@ +use crate::composer::pcre::preg::Preg; +use crate::symfony::console::application::Application; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::descriptor::application_description::ApplicationDescription; +use crate::symfony::console::descriptor::descriptor::Descriptor; +use crate::symfony::console::descriptor::descriptor_interface::DescriptorInterface; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; +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: PhpMixed, + 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().len() > 0; + 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().len() > 0 { + 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: &mut 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<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(shirabe_php_shim::sprintf( + "* [`%s`](#%s)", + &[ + PhpMixed::String(command_name.clone()), + PhpMixed::String(shirabe_php_shim::str_replace( + ":", + "", + &description + .get_command(command_name)? + .borrow() + .get_name() + .unwrap_or_default(), + )), + ], + )) + }) + .collect::<anyhow::Result<Vec<String>>>()? + .join("\n"), + true, + ); + } + + let command_list: Vec<_> = description + .get_commands() + .values() + .map(|c| c.borrow().clone_box()) + .collect(); + for mut command in command_list { + self.write("\n\n", true); + // describeCommand returns null; the guarded write never runs. + self.describe_command(command.as_mut(), options.clone())?; + } + Ok(()) + } + + fn get_application_title(&self, application: &Application) -> String { + if "UNKNOWN" != application.get_name() { + if "UNKNOWN" != application.get_version() { + return shirabe_php_shim::sprintf( + "%s %s", + &[ + PhpMixed::String(application.get_name()), + PhpMixed::String(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: PhpMixed, + 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::symfony::console::output::output_interface::OUTPUT_NORMAL + } else { + crate::symfony::console::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: &mut 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<Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/descriptor/mod.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/mod.rs new file mode 100644 index 0000000..9f85f92 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/mod.rs @@ -0,0 +1,15 @@ +pub mod application_description; +pub mod descriptor; +pub mod descriptor_interface; +pub mod json_descriptor; +pub mod markdown_descriptor; +pub mod text_descriptor; +pub mod xml_descriptor; + +pub use application_description::*; +pub use descriptor::*; +pub use descriptor_interface::*; +pub use json_descriptor::*; +pub use markdown_descriptor::*; +pub use text_descriptor::*; +pub use xml_descriptor::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/descriptor/text_descriptor.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/text_descriptor.rs new file mode 100644 index 0000000..963fcd8 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/text_descriptor.rs @@ -0,0 +1,617 @@ +use crate::composer::pcre::preg::Preg; +use crate::symfony::console::application::Application; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::descriptor::application_description::ApplicationDescription; +use crate::symfony::console::descriptor::descriptor::Descriptor; +use crate::symfony::console::descriptor::descriptor_interface::DescriptorInterface; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; +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( + &shirabe_php_shim::sprintf( + " <info>%s</info> %s%s%s", + &[ + PhpMixed::String(argument.get_name().to_string()), + PhpMixed::String(shirabe_php_shim::str_repeat(" ", spacing_width as usize)), + // + 4 = 2 spaces before <info>, 2 spaces after </info> + PhpMixed::String(Preg::replace( + "/\\s*[\\r\\n]\\s*/", + &format!( + "\n{}", + shirabe_php_shim::str_repeat(" ", (total_width + 4) as usize) + ), + argument.get_description(), + )?), + PhpMixed::String(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() { + shirabe_php_shim::sprintf( + "-%s, ", + &[PhpMixed::String(option.get_shortcut().unwrap().to_string())], + ) + } else { + " ".to_string() + }, + if option.is_negatable() { + shirabe_php_shim::sprintf( + "--%1$s|--no-%1$s", + &[ + PhpMixed::String(option.get_name().to_string()), + PhpMixed::String(value.clone()), + ], + ) + } else { + shirabe_php_shim::sprintf( + "--%1$s%2$s", + &[ + PhpMixed::String(option.get_name().to_string()), + PhpMixed::String(value.clone()), + ], + ) + } + ); + + let spacing_width = total_width - Helper::width(&synopsis); + + self.write_text( + &shirabe_php_shim::sprintf( + " <info>%s</info> %s%s%s%s", + &[ + PhpMixed::String(synopsis), + PhpMixed::String(shirabe_php_shim::str_repeat(" ", spacing_width as usize)), + // + 4 = 2 spaces before <info>, 2 spaces after </info> + PhpMixed::String(Preg::replace( + "/\\s*[\\r\\n]\\s*/", + &format!( + "\n{}", + shirabe_php_shim::str_repeat(" ", (total_width + 4) as usize) + ), + option.get_description(), + )?), + PhpMixed::String(default), + PhpMixed::String(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: &mut 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<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( + &shirabe_php_shim::sprintf( + &format!("%-{}s %s", width), + &[ + PhpMixed::String(command.get_name().unwrap_or_default()), + PhpMixed::String(command.get_description()), + ], + ), + &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); + + // PHP: new InputDefinition($application->getDefinition()->getOptions()). + // `InputOption` is not Clone and lives behind `Rc`, so the option-only + // definition cannot be reconstructed by value yet. + let definition: InputDefinition = + todo!("new InputDefinition($application->getDefinition()->getOptions())"); + 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.as_ref() { + if command_keys.contains(name) { + merged.push(CommandOrString::String(name.clone())); + } + } + } + } + } + merged + }); + + if let Some(ref described_namespace) = described_namespace { + self.write_text( + &shirabe_php_shim::sprintf( + "<comment>Available commands for the \"%s\" namespace:</comment>", + &[PhpMixed::String(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.as_ref() { + 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( + &shirabe_php_shim::sprintf( + " <info>%s</info>%s%s", + &[ + PhpMixed::String(name.clone()), + PhpMixed::String(shirabe_php_shim::str_repeat( + " ", + spacing_width as usize, + )), + PhpMixed::String(format!( + "{}{}", + 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.as_ref() { + *value = Box::new(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.as_ref() { + *value = Box::new(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(), + )) + } + + /// @param array<Command|string> $commands + 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 + } + } + + /// @param InputOption[] $options + 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 + + std::cmp::max(Helper::width(option.get_shortcut().unwrap_or("")), 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: PhpMixed, + 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: &mut 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<Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + TextDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/descriptor/xml_descriptor.rs b/crates/shirabe-external-packages/src/symfony/console/descriptor/xml_descriptor.rs new file mode 100644 index 0000000..209964a --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/descriptor/xml_descriptor.rs @@ -0,0 +1,428 @@ +use crate::symfony::console::application::Application; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::descriptor::application_description::ApplicationDescription; +use crate::symfony::console::descriptor::descriptor::Descriptor; +use crate::symfony::console::descriptor::descriptor_interface::DescriptorInterface; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +// NOTE: This descriptor relies on the PHP standard classes \DOMDocument and \DOMNode. +// No equivalent has been introduced into the project yet, so every DOM operation is left +// as todo!() pending a decision on how to model the DOM types. The type `DOMDocument` / +// `DOMNode` placeholders below stand in for those PHP classes. + +/// Placeholder for the PHP standard class \DOMDocument. +#[derive(Debug)] +pub struct DOMDocument; + +/// Placeholder for the PHP standard class \DOMNode. +#[derive(Debug)] +pub struct DOMNode; + +/// 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 = todo!("new \\DOMDocument('1.0', 'UTF-8')"); + let definition_xml: DOMNode = todo!("$dom->createElement('definition')"); + // $dom->appendChild($definitionXML) + + let arguments_xml: DOMNode = todo!("$dom->createElement('arguments')"); + // $definitionXML->appendChild($argumentsXML) + for argument in definition.get_arguments().values() { + self.append_document(&arguments_xml, &self.get_input_argument_document(argument)); + } + + let options_xml: DOMNode = todo!("$dom->createElement('options')"); + // $definitionXML->appendChild($optionsXML) + for option in definition.get_options().values() { + self.append_document(&options_xml, &self.get_input_option_document(option)); + } + + dom + } + + pub fn get_command_document(&self, command: &mut dyn Command, short: bool) -> DOMDocument { + let dom: DOMDocument = todo!("new \\DOMDocument('1.0', 'UTF-8')"); + let command_xml: DOMNode = todo!("$dom->createElement('command')"); + // $dom->appendChild($commandXML) + + // $commandXML->setAttribute('id', $command->getName()) + let _ = command.get_name(); + // $commandXML->setAttribute('name', $command->getName()) + // $commandXML->setAttribute('hidden', $command->isHidden() ? 1 : 0) + let _ = command.is_hidden(); + + let usages_xml: DOMNode = todo!("$dom->createElement('usages'); appendChild"); + + let _description_xml: DOMNode = todo!("$dom->createElement('description'); appendChild"); + // $descriptionXML->appendChild($dom->createTextNode(str_replace("\n", "\n ", $command->getDescription()))) + let _ = shirabe_php_shim::str_replace("\n", "\n ", &command.get_description()); + + if short { + for usage in command.get_aliases() { + let _ = usage; + // $usagesXML->appendChild($dom->createElement('usage', $usage)) + let _ = &usages_xml; + todo!("createElement('usage', $usage) and append"); + } + } 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 { + let _ = usage; + todo!("createElement('usage', $usage) and append"); + } + + let _help_xml: DOMNode = todo!("$dom->createElement('help'); appendChild"); + // $helpXML->appendChild($dom->createTextNode(str_replace("\n", "\n ", $command->getProcessedHelp()))) + let _ = 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_xml; + // $this->appendDocument($commandXML, $definitionXML->getElementsByTagName('definition')->item(0)) + self.append_document( + &command_xml, + todo!("$definitionXML->getElementsByTagName('definition')->item(0)"), + ); + } + + dom + } + + pub fn get_application_document( + &self, + application: std::rc::Rc<std::cell::RefCell<Application>>, + namespace: Option<String>, + short: bool, + ) -> DOMDocument { + let dom: DOMDocument = todo!("new \\DOMDocument('1.0', 'UTF-8')"); + let root_xml: DOMNode = todo!("$dom->createElement('symfony'); appendChild"); + + if "UNKNOWN" != application.borrow().get_name() { + // $rootXml->setAttribute('name', $application->getName()) + if "UNKNOWN" != application.borrow().get_version() { + // $rootXml->setAttribute('version', $application->getVersion()) + } + } + + let commands_xml: DOMNode = + todo!("$dom->createElement('commands'); appendChild to rootXml"); + + let mut description = + ApplicationDescription::new(application.clone(), namespace.clone(), true); + + if let Some(ref namespace) = namespace { + let _ = namespace; + // $commandsXML->setAttribute('namespace', $namespace) + } + + let command_list: Vec<_> = description + .get_commands() + .values() + .map(|c| c.borrow().clone_box()) + .collect(); + for mut command in command_list { + self.append_document( + &commands_xml, + &self.get_command_document(command.as_mut(), short), + ); + } + + if namespace.is_none() { + let _namespaces_xml: DOMNode = + todo!("$dom->createElement('namespaces'); appendChild to rootXml"); + let _ = &root_xml; + + for namespace_description in description.get_namespaces().values() { + let _namespace_array_xml: DOMNode = + todo!("$dom->createElement('namespace'); append; setAttribute('id', ...)"); + let _ = match namespace_description.get("id") { + Some(PhpMixed::String(s)) => s.clone(), + _ => String::new(), + }; + + if let Some(PhpMixed::List(names)) = namespace_description.get("commands") { + for name in names { + let _ = name.as_string(); + // $commandXML = $dom->createElement('command'); append; + // $commandXML->appendChild($dom->createTextNode($name)) + todo!("create command element with text node $name"); + } + } + } + } + + 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: &mut 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<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. + /// + /// In PHP `DOMDocument` extends `DOMNode`; the placeholder model accepts a + /// `DOMDocument` for `imported_parent` to match the call sites. + fn append_document(&self, parent_node: &DOMNode, imported_parent: &DOMDocument) { + let _ = (parent_node, imported_parent); + // foreach ($importedParent->childNodes as $childNode) { + // $parentNode->appendChild($parentNode->ownerDocument->importNode($childNode, true)); + // } + todo!("DOM import/append of child nodes"); + } + + /// Writes DOM document. + fn write_document(&self, dom: DOMDocument) { + // $dom->formatOutput = true; + // $this->write($dom->saveXML()); + let xml: String = todo!("$dom->saveXML() with formatOutput = true"); + let _ = dom; + self.write(&xml, false); + } + + fn get_input_argument_document(&self, argument: &InputArgument) -> DOMDocument { + let dom: DOMDocument = todo!("new \\DOMDocument('1.0', 'UTF-8')"); + + // $objectXML = $dom->createElement('argument'); appendChild + // $objectXML->setAttribute('name', $argument->getName()) + let _ = argument.get_name(); + // $objectXML->setAttribute('is_required', $argument->isRequired() ? 1 : 0) + let _ = argument.is_required(); + // $objectXML->setAttribute('is_array', $argument->isArray() ? 1 : 0) + let _ = argument.is_array(); + // $descriptionXML = $dom->createElement('description'); append; + // $descriptionXML->appendChild($dom->createTextNode($argument->getDescription())) + let _ = argument.get_description(); + + // $defaultsXML = $dom->createElement('defaults'); append + 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; + // $defaultXML = $dom->createElement('default'); append; + // $defaultXML->appendChild($dom->createTextNode($default)) + todo!("create default element with text node"); + } + + dom + } + + fn get_input_option_document(&self, option: &InputOption) -> DOMDocument { + let dom: DOMDocument = todo!("new \\DOMDocument('1.0', 'UTF-8')"); + + // $objectXML = $dom->createElement('option'); appendChild + // $objectXML->setAttribute('name', '--'.$option->getName()) + let _ = format!("--{}", option.get_name()); + let pos = shirabe_php_shim::strpos(option.get_shortcut().unwrap_or(""), "|"); + if let Some(pos) = pos { + // $objectXML->setAttribute('shortcut', '-'.substr($option->getShortcut(), 0, $pos)) + let _ = format!( + "-{}", + shirabe_php_shim::substr(option.get_shortcut().unwrap(), 0, Some(pos as i64)) + ); + // $objectXML->setAttribute('shortcuts', '-'.str_replace('|', '|-', $option->getShortcut())) + let _ = format!( + "-{}", + shirabe_php_shim::str_replace("|", "|-", option.get_shortcut().unwrap()) + ); + } else { + // $objectXML->setAttribute('shortcut', $option->getShortcut() ? '-'.$option->getShortcut() : '') + let _ = match option.get_shortcut() { + Some(s) => format!("-{}", s), + None => String::new(), + }; + } + // $objectXML->setAttribute('accept_value', $option->acceptValue() ? 1 : 0) + let _ = option.accept_value(); + // $objectXML->setAttribute('is_value_required', $option->isValueRequired() ? 1 : 0) + let _ = option.is_value_required(); + // $objectXML->setAttribute('is_multiple', $option->isArray() ? 1 : 0) + let _ = option.is_array(); + // $descriptionXML = $dom->createElement('description'); append; + // $descriptionXML->appendChild($dom->createTextNode($option->getDescription())) + let _ = 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![], + }; + // $defaultsXML = $dom->createElement('defaults'); append + + if !defaults.is_empty() { + for default in defaults { + let _ = default; + // $defaultXML = $dom->createElement('default'); append; + // $defaultXML->appendChild($dom->createTextNode($default)) + todo!("create default element with text node"); + } + } + } + + if option.is_negatable() { + // $objectXML = $dom->createElement('option'); $dom->appendChild + // $objectXML->setAttribute('name', '--no-'.$option->getName()) + let _ = format!("--no-{}", option.get_name()); + // setAttribute('shortcut', ''); setAttribute('accept_value', 0); + // setAttribute('is_value_required', 0); setAttribute('is_multiple', 0); + // $descriptionXML = $dom->createElement('description'); append; + // $descriptionXML->appendChild($dom->createTextNode('Negate the "--'.$option->getName().'" option')) + let _ = 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(|v| shirabe_php_shim::php_to_string(v)) + .collect(), + PhpMixed::Array(arr) => arr + .values() + .map(|v| shirabe_php_shim::php_to_string(v)) + .collect(), + _ => vec![], + } + } +} + +impl DescriptorInterface for XmlDescriptor { + fn describe( + &mut self, + output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + object: PhpMixed, + 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: &mut 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<Application>>, + options: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_application(self, application, options) + } +} |
