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 | |
| 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')
102 files changed, 18087 insertions, 584 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/application.rs b/crates/shirabe-external-packages/src/symfony/console/application.rs index a939bee..83309f4 100644 --- a/crates/shirabe-external-packages/src/symfony/console/application.rs +++ b/crates/shirabe-external-packages/src/symfony/console/application.rs @@ -1,133 +1,1719 @@ -use crate::symfony::console::input::InputDefinition; -use crate::symfony::console::input::InputInterface; -use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::command::complete_command::CompleteCommand; +use crate::symfony::console::command::dump_completion_command::DumpCompletionCommand; +use crate::symfony::console::command::help_command::HelpCommand; +use crate::symfony::console::command::lazy_command::LazyCommand; +use crate::symfony::console::command::list_command::ListCommand; +use crate::symfony::console::command::signalable_command_interface::SignalableCommandInterface; +use crate::symfony::console::command_loader::command_loader_interface::CommandLoaderInterface; +use crate::symfony::console::completion::completion_input::CompletionInput; +use crate::symfony::console::completion::completion_suggestions::CompletionSuggestions; +use crate::symfony::console::console_events::ConsoleEvents; +use crate::symfony::console::event::console_command_event::ConsoleCommandEvent; +use crate::symfony::console::event::console_error_event::ConsoleErrorEvent; +use crate::symfony::console::event::console_signal_event::ConsoleSignalEvent; +use crate::symfony::console::event::console_terminate_event::ConsoleTerminateEvent; +use crate::symfony::console::exception::command_not_found_exception::CommandNotFoundException; +use crate::symfony::console::exception::exception_interface::ExceptionInterface; +use crate::symfony::console::exception::logic_exception::LogicException; +use crate::symfony::console::exception::namespace_not_found_exception::NamespaceNotFoundException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::helper::debug_formatter_helper::DebugFormatterHelper; +use crate::symfony::console::helper::formatter_helper::{FormatBlockMessages, FormatterHelper}; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::helper::process_helper::ProcessHelper; +use crate::symfony::console::helper::question_helper::QuestionHelper; +use crate::symfony::console::input::argv_input::ArgvInput; +use crate::symfony::console::input::array_input::ArrayInput; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_aware_interface::InputAwareInterface; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::console_output::ConsoleOutput; +use crate::symfony::console::output::console_output_interface::ConsoleOutputInterface; +use crate::symfony::console::output::output_interface::{self, OutputInterface}; +use crate::symfony::console::signal_registry::signal_registry::SignalRegistry; +use crate::symfony::console::style::style_interface::StyleInterface; +use crate::symfony::console::style::symfony_style::SymfonyStyle; +use crate::symfony::console::terminal::Terminal; +use crate::symfony::contracts::event_dispatcher::event_dispatcher_interface::EventDispatcherInterface; +use crate::symfony::contracts::service::reset_interface::ResetInterface; +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; +/// An Application is the container for a collection of commands. +/// +/// It is the main entry point of a Console application. +/// +/// This class is optimized for a standard CLI environment. #[derive(Debug)] -pub struct Application; +pub struct Application { + commands: IndexMap<String, Rc<RefCell<dyn Command>>>, + want_helps: bool, + running_command: Option<Rc<RefCell<dyn Command>>>, + name: String, + version: String, + command_loader: Option<Box<dyn CommandLoaderInterface>>, + catch_exceptions: bool, + auto_exit: bool, + definition: Option<Rc<RefCell<InputDefinition>>>, + helper_set: Option<Rc<RefCell<HelperSet>>>, + dispatcher: Option<Rc<RefCell<dyn EventDispatcherInterface>>>, + terminal: Terminal, + default_command: String, + single_command: bool, + initialized: bool, + signal_registry: Option<SignalRegistry>, + signals_to_dispatch_event: Vec<i64>, +} impl Application { - pub fn new(_name: &str, _version: &str) -> Self { - todo!() + pub fn __construct(name: &str, version: &str) -> Self { + let mut this = Application { + commands: IndexMap::new(), + want_helps: false, + running_command: None, + name: name.to_string(), + version: version.to_string(), + command_loader: None, + catch_exceptions: true, + auto_exit: true, + definition: None, + helper_set: None, + dispatcher: None, + terminal: Terminal::new(), + default_command: "list".to_string(), + single_command: false, + initialized: false, + signal_registry: None, + signals_to_dispatch_event: Vec::new(), + }; + if shirabe_php_shim::defined("SIGINT") && SignalRegistry::is_supported() { + this.signal_registry = Some(SignalRegistry::new()); + this.signals_to_dispatch_event = vec![ + shirabe_php_shim::SIGINT, + shirabe_php_shim::SIGTERM, + shirabe_php_shim::SIGUSR1, + shirabe_php_shim::SIGUSR2, + ]; + } + this + } + + /// @final + pub fn set_dispatcher(&mut self, dispatcher: Rc<RefCell<dyn EventDispatcherInterface>>) { + // TODO(plugin): the event dispatcher drives ConsoleEvents listeners (plugins). + self.dispatcher = Some(dispatcher); + } + + pub fn set_command_loader(&mut self, command_loader: Box<dyn CommandLoaderInterface>) { + self.command_loader = Some(command_loader); + } + + pub fn get_signal_registry(&self) -> anyhow::Result<&SignalRegistry> { + match &self.signal_registry { + None => Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: "Signals are not supported. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), + code: 0, + }) + .into()), + Some(signal_registry) => Ok(signal_registry), + } } + pub fn set_signals_to_dispatch_event(&mut self, signals_to_dispatch_event: Vec<i64>) { + self.signals_to_dispatch_event = signals_to_dispatch_event; + } + + /// Runs the current application. + /// + /// Returns 0 if everything went fine, or an error code. + /// + /// Throws \Exception when running fails. Bypass this when set_catch_exceptions(). pub fn run( &mut self, - _input: Option<std::rc::Rc<std::cell::RefCell<dyn InputInterface>>>, - _output: Option<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, + input: Option<Rc<RefCell<dyn InputInterface>>>, + output: Option<Rc<RefCell<dyn OutputInterface>>>, ) -> anyhow::Result<i64> { - todo!() + if shirabe_php_shim::function_exists("putenv") { + shirabe_php_shim::putenv(&format!("LINES={}", self.terminal.get_height())); + shirabe_php_shim::putenv(&format!("COLUMNS={}", self.terminal.get_width())); + } + + let input: Rc<RefCell<dyn InputInterface>> = match input { + None => Rc::new(RefCell::new(ArgvInput::new(None, None)?)), + Some(input) => input, + }; + + let output: Rc<RefCell<dyn OutputInterface>> = match output { + None => Rc::new(RefCell::new(ConsoleOutput::new(None, None, None)?)), + Some(output) => output, + }; + + // TODO: PHP installs a temporary `set_exception_handler($renderException)` and cooperates + // with Symfony's ErrorHandler to keep/restore it. PHP's process-global exception handler + // stack has no Rust equivalent; the rendering itself is invoked directly in the catch + // branch below. Review needed for the handler save/restore dance. + let render_exception = + |this: &Application, e: &anyhow::Error, output: &Rc<RefCell<dyn OutputInterface>>| { + // if ($output instanceof ConsoleOutputInterface) render to its error output + // TODO(review): downcasting a `dyn OutputInterface` to `ConsoleOutputInterface` + // is not directly expressible; the ConsoleOutputInterface branch needs design. + this.render_throwable(e, output.clone()); + }; + + let result = (|| -> anyhow::Result<i64> { + self.configure_io(&input, &output)?; + + let exit_code = self.do_run(input.clone(), output.clone())?; + + Ok(exit_code) + })(); + + let mut exit_code = match result { + Ok(exit_code) => exit_code, + Err(e) => { + if !self.catch_exceptions { + return Err(e); + } + + render_exception(self, &e, &output); + + // $exitCode = $e->getCode(); + // is_numeric($exitCode) ? max(1, (int) $exitCode) : 1 + // TODO(review): anyhow::Error has no PHP-style getCode(); the exit code derived + // from the exception's `code` field needs the downcast strategy decided. + let exit_code = shirabe_php_shim::php_exception_get_code(&e); + if shirabe_php_shim::is_numeric_string(&exit_code.to_string()) { + let exit_code = exit_code; + if exit_code <= 0 { 1 } else { exit_code } + } else { + 1 + } + } + }; + + // finally: handler restore. See TODO above; no-op here. + + if self.auto_exit { + if exit_code > 255 { + exit_code = 255; + } + + shirabe_php_shim::exit(exit_code); + } + + Ok(exit_code) } - pub fn set_name(&mut self, _name: &str) { - todo!() + /// Runs the current application. + /// + /// Returns 0 if everything went fine, or an error code. + pub fn do_run( + &mut self, + input: Rc<RefCell<dyn InputInterface>>, + output: Rc<RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<i64> { + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--version".to_string()), + PhpMixed::from("-V".to_string()), + ]), + true, + ) { + output + .borrow() + .writeln(&[self.get_long_version()], output_interface::OUTPUT_NORMAL); + + return Ok(0); + } + + // Makes ArgvInput::getFirstArgument() able to distinguish an option from an argument. + match input.borrow_mut().bind(&self.get_definition().borrow()) { + Ok(()) => {} + Err(e) => { + // Errors must be ignored, full binding/validation happens later when the command is known. + if !is_exception_interface(&e) { + return Err(e); + } + } + } + + let mut input = input; + let mut name = self.get_command_name(&*input.borrow()); + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--help".to_string()), + PhpMixed::from("-h".to_string()), + ]), + true, + ) { + if name.is_none() { + name = Some("help".to_string()); + input = Rc::new(RefCell::new(ArrayInput::new( + vec![( + PhpMixed::from("command_name".to_string()), + PhpMixed::from(self.default_command.clone()), + )], + None, + )?)); + } else { + self.want_helps = true; + } + } + + let name = match name { + Some(name) => name, + None => { + let name = self.default_command.clone(); + let definition = self.get_definition(); + let command_description = definition + .borrow() + .get_argument(&PhpMixed::from("command".to_string()))? + .get_description() + .to_string(); + let _new_command_argument = InputArgument::new( + "command".to_string(), + Some(InputArgument::OPTIONAL), + command_description, + PhpMixed::from(name.clone()), + )?; + // $definition->setArguments(array_merge($definition->getArguments(), + // ['command' => new InputArgument('command', InputArgument::OPTIONAL, ...)])) + // TODO(review): get_arguments() yields Rc<InputArgument> (shared, non-Clone) while + // set_arguments() consumes owned InputArgument values. Re-building the merged + // argument list requires an InputArgument clone/ownership strategy not yet present. + definition.borrow_mut().set_arguments(todo!( + "merge existing arguments with the new 'command' argument" + ))?; + + name + } + }; + + let command: Rc<RefCell<dyn Command>>; + let find_result = (|| -> anyhow::Result<Rc<RefCell<dyn Command>>> { + self.running_command = None; + // the command name MUST be the first element of the input + self.find(&name) + })(); + + match find_result { + Ok(c) => { + command = c; + } + Err(e) => { + // if (!($e instanceof CommandNotFoundException && !$e instanceof NamespaceNotFoundException) + // || 1 !== count($alternatives = $e->getAlternatives()) || !$input->isInteractive()) + let alternatives: Option<Vec<String>> = downcast_command_not_found(&e) + .filter(|_| !is_namespace_not_found(&e)) + .map(|cnf| cnf.get_alternatives().clone()); + + let single_alternative = match &alternatives { + Some(alts) if alts.len() == 1 => Some(alts[0].clone()), + _ => None, + }; + + if single_alternative.is_none() || !input.borrow().is_interactive() { + let mut e = e; + if self.dispatcher.is_some() { + // TODO(plugin): dispatch ConsoleErrorEvent so listeners can handle/replace the error. + let _event = ConsoleErrorEvent::new( + todo!("wrap input as Box<dyn InputInterface> for the event"), + todo!("wrap output as Box<dyn OutputInterface> for the event"), + todo!("wrap anyhow::Error as Box<dyn Error> for the event"), + None, + ); + let event: ConsoleErrorEvent = _event; + self.dispatcher + .as_ref() + .unwrap() + .borrow_mut() + .dispatch(todo!("event object"), ConsoleEvents::ERROR); + + if event.get_exit_code() == 0 { + return Ok(0); + } + + e = todo!("event.get_error() converted back to anyhow::Error"); + } + + return Err(e); + } + + let alternative = single_alternative.unwrap(); + + let mut style = SymfonyStyle::new(input.clone(), output.clone()); + output + .borrow() + .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); + let formatted_block = FormatterHelper::default().format_block( + FormatBlockMessages::String(shirabe_php_shim::sprintf( + "Command \"%s\" is not defined.", + &[PhpMixed::from(name.clone())], + )), + "error", + true, + ); + output + .borrow() + .writeln(&[formatted_block], output_interface::OUTPUT_NORMAL); + if !style.confirm( + &shirabe_php_shim::sprintf( + "Do you want to run \"%s\" instead? ", + &[PhpMixed::from(alternative.clone())], + ), + false, + ) { + if self.dispatcher.is_some() { + // TODO(plugin): dispatch ConsoleErrorEvent for the declined-alternative case. + let event = ConsoleErrorEvent::new( + todo!("wrap input as Box<dyn InputInterface>"), + todo!("wrap output as Box<dyn OutputInterface>"), + todo!("wrap error as Box<dyn Error>"), + None, + ); + self.dispatcher + .as_ref() + .unwrap() + .borrow_mut() + .dispatch(todo!("event object"), ConsoleEvents::ERROR); + + return Ok(event.get_exit_code()); + } + + return Ok(1); + } + + command = self.find(&alternative)?; + } + } + + // if ($command instanceof LazyCommand) $command = $command->getCommand(); + // TODO(review): LazyCommand is a distinct type from Command here; PHP unwraps the real + // command. The `commands` map stores Rc<RefCell<dyn Command>>, so the LazyCommand-unwrap path + // needs a design decision about how lazy commands are represented. + let _ = std::marker::PhantomData::<LazyCommand>; + + self.running_command = Some(command.clone()); + let exit_code = self.do_run_command(command.clone(), input.clone(), output.clone())?; + self.running_command = None; + + Ok(exit_code) } - pub fn get_name(&self) -> String { - todo!() + pub fn reset(&mut self) {} + + pub fn set_helper_set(&mut self, helper_set: Rc<RefCell<HelperSet>>) { + self.helper_set = Some(helper_set); } - pub fn set_version(&mut self, _version: &str) { - todo!() + /// Get the helper set associated with the command. + pub fn get_helper_set(&mut self) -> Rc<RefCell<HelperSet>> { + if self.helper_set.is_none() { + self.helper_set = Some(self.get_default_helper_set()); + } + + self.helper_set.as_ref().unwrap().clone() } - pub fn get_version(&self) -> String { - todo!() + pub fn set_definition(&mut self, definition: Rc<RefCell<InputDefinition>>) { + self.definition = Some(definition); } - pub fn add(&mut self, _command: PhpMixed) -> Option<PhpMixed> { - todo!() + /// Gets the InputDefinition related to this Application. + pub fn get_definition(&mut self) -> Rc<RefCell<InputDefinition>> { + if self.definition.is_none() { + self.definition = Some(Rc::new(RefCell::new(self.get_default_input_definition()))); + } + + if self.single_command { + let input_definition = self.definition.as_ref().unwrap().clone(); + input_definition + .borrow_mut() + .set_arguments(Vec::new()) + .unwrap(); + + return input_definition; + } + + self.definition.as_ref().unwrap().clone() } - pub fn get(&self, _name: &str) -> anyhow::Result<PhpMixed> { - todo!() + /// Adds suggestions to `suggestions` for the current completion input (e.g. option or argument). + pub fn complete( + &mut self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + if CompletionInput::TYPE_ARGUMENT_VALUE == input.get_completion_type() + && input.get_completion_name().as_deref() == Some("command") + { + let mut command_names: Vec<PhpMixed> = Vec::new(); + for (name, command) in self.all(None)? { + // skip hidden commands and aliased commands as they already get added below + if command.borrow().is_hidden() || command.borrow().get_name() != Some(name.clone()) + { + continue; + } + command_names.push(PhpMixed::from( + command.borrow().get_name().unwrap_or_default(), + )); + for name in command.borrow().get_aliases() { + command_names.push(PhpMixed::from(name)); + } + } + // array_filter($commandNames) + let filtered: Vec<crate::symfony::console::completion::completion_suggestions::StringOrSuggestion> = + command_names + .into_iter() + .filter(|n| shirabe_php_shim::php_truthy(n)) + .map(|n| { + crate::symfony::console::completion::completion_suggestions::StringOrSuggestion::String( + shirabe_php_shim::php_to_string(&n), + ) + }) + .collect(); + suggestions.suggest_values(filtered); + + return Ok(()); + } + + if CompletionInput::TYPE_OPTION_NAME == input.get_completion_type() { + // $suggestions->suggestOptions($this->getDefinition()->getOptions()); + // TODO(review): get_options() yields Rc<InputOption> (shared, non-Clone) while + // suggest_options() consumes owned InputOption values; an ownership/clone strategy + // for InputOption is needed. + suggestions.suggest_options(todo!("owned options from get_definition().get_options()")); + + return Ok(()); + } + + Ok(()) + } + + /// Gets the help message. + pub fn get_help(&self) -> String { + self.get_long_version() + } + + /// Gets whether to catch exceptions or not during commands execution. + pub fn are_exceptions_caught(&self) -> bool { + self.catch_exceptions + } + + /// Sets whether to catch exceptions or not during commands execution. + pub fn set_catch_exceptions(&mut self, boolean: bool) { + self.catch_exceptions = boolean; } - pub fn set_auto_exit(&mut self, _auto_exit: bool) { - todo!() + /// Gets whether to automatically exit after a command execution or not. + pub fn is_auto_exit_enabled(&self) -> bool { + self.auto_exit } - pub fn set_catch_exceptions(&mut self, _catch_exceptions: bool) { - todo!() + /// Sets whether to automatically exit after a command execution or not. + pub fn set_auto_exit(&mut self, boolean: bool) { + self.auto_exit = boolean; } - pub fn get_helper_set(&self) -> PhpMixed { - todo!() + /// Gets the name of the application. + pub fn get_name(&self) -> String { + self.name.clone() } - pub fn set_helper_set(&mut self, _helper_set: PhpMixed) { - todo!() + /// Sets the application name. + pub fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } + + /// Gets the application version. + pub fn get_version(&self) -> String { + self.version.clone() } - pub fn get_definition(&self) -> PhpMixed { - todo!() + /// Sets the application version. + pub fn set_version(&mut self, version: &str) { + self.version = version.to_string(); } + /// Returns the long version of the application. pub fn get_long_version(&self) -> String { - todo!() + if "UNKNOWN" != self.get_name() { + if "UNKNOWN" != self.get_version() { + return shirabe_php_shim::sprintf( + "%s <info>%s</info>", + &[ + PhpMixed::from(self.get_name()), + PhpMixed::from(self.get_version()), + ], + ); + } + + return self.get_name(); + } + + "Console Tool".to_string() + } + + /// Adds an array of command objects. + /// + /// If a Command is not enabled it will not be added. + pub fn add_commands(&mut self, commands: Vec<Rc<RefCell<dyn Command>>>) -> anyhow::Result<()> { + for command in commands { + self.add(command)?; + } + Ok(()) + } + + /// Adds a command object. + /// + /// If a command with the same name already exists, it will be overridden. + /// If the command is not enabled it will not be added. + pub fn add( + &mut self, + command: Rc<RefCell<dyn Command>>, + ) -> anyhow::Result<Option<Rc<RefCell<dyn Command>>>> { + self.init()?; + + // TODO(review): $command->setApplication($this) needs an Rc<RefCell<Application>> to the + // current instance. Application is held by value here; the self-reference required to set + // the command's back-pointer needs the shared-ownership design (Phase C). + command + .borrow_mut() + .set_application(todo!("Rc<RefCell<Application>> of self")); + + if !command.borrow().is_enabled() { + command.borrow_mut().set_application(None); + + return Ok(None); + } + + // if (!$command instanceof LazyCommand) { $command->getDefinition(); } + // TODO(review): LazyCommand vs Command type distinction; eager definition probe omitted + // pending lazy-command representation decision. + command.borrow().get_definition(); + + if command.borrow().get_name().is_none() { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "The command defined in \"%s\" cannot have an empty name.", + &[PhpMixed::from(shirabe_php_shim::get_debug_type_obj( + &command, + ))], + ), + code: 0, + }) + .into()); + } + + let name = command.borrow().get_name().unwrap(); + self.commands.insert(name, command.clone()); + + for alias in command.borrow().get_aliases() { + self.commands.insert(alias, command.clone()); + } + + Ok(Some(command)) } - pub fn find(&self, _name: &str) -> anyhow::Result<PhpMixed> { - todo!() + /// Returns a registered command by name or alias. + /// + /// Throws CommandNotFoundException when given command name does not exist. + pub fn get(&mut self, name: &str) -> anyhow::Result<Rc<RefCell<dyn Command>>> { + self.init()?; + + if !self.has(name) { + return Err(CommandNotFoundException::new( + shirabe_php_shim::sprintf( + "The command \"%s\" does not exist.", + &[PhpMixed::from(name.to_string())], + ), + Vec::new(), + 0, + ) + .into()); + } + + // When the command has a different name than the one used at the command loader level + if !self.commands.contains_key(name) { + return Err(CommandNotFoundException::new( + shirabe_php_shim::sprintf( + "The \"%s\" command cannot be found because it is registered under multiple names. Make sure you don't set a different name via constructor or \"setName()\".", + &[PhpMixed::from(name.to_string())], + ), + Vec::new(), + 0, + ) + .into()); + } + + let command = self.commands[name].clone(); + + if self.want_helps { + self.want_helps = false; + + let help_command = self.get("help")?; + // $helpCommand->setCommand($command); + // TODO(review): setCommand() is defined on HelpCommand, not on the concrete `Command` + // struct; calling it through the Rc<RefCell<dyn Command>> needs the Command-subclass + // representation decision (downcast to HelpCommand). + let _ = &command; + todo!("help_command.set_command(command)"); + + #[allow(unreachable_code)] + return Ok(help_command); + } + + Ok(command) } - pub fn all(&self, _namespace: Option<&str>) -> Vec<PhpMixed> { - todo!() + /// Returns true if the command exists, false otherwise. + pub fn has(&mut self, name: &str) -> bool { + self.init().unwrap(); + + if self.commands.contains_key(name) { + return true; + } + + if let Some(command_loader) = &self.command_loader { + if command_loader.has(name) { + let command = command_loader.get(name); + // $this->add($this->commandLoader->get($name)) + // TODO(review): command_loader.get() returns Box<dyn Command> while add() expects + // Rc<RefCell<dyn Command>>; the loader return type needs reconciliation. + let _ = command; + return self + .add(todo!( + "Rc<RefCell<dyn Command>> from command_loader.get(name)" + )) + .map(|c| c.is_some()) + .unwrap_or(false); + } + } + + false } - pub fn get_namespaces(&self) -> Vec<String> { - todo!() + /// Returns an array of all unique namespaces used by currently registered commands. + /// + /// It does not return the global namespace which always exists. + pub fn get_namespaces(&mut self) -> anyhow::Result<Vec<String>> { + let mut namespaces: Vec<Vec<String>> = Vec::new(); + for command in self.all(None)?.values() { + if command.borrow().is_hidden() { + continue; + } + + namespaces.push( + self.extract_all_namespaces(&command.borrow().get_name().unwrap_or_default()), + ); + + for alias in command.borrow().get_aliases() { + namespaces.push(self.extract_all_namespaces(&alias)); + } + } + + // array_values(array_unique(array_filter(array_merge([], ...$namespaces)))) + let mut merged: Vec<String> = Vec::new(); + for ns in namespaces { + merged.extend(ns); + } + let merged: Vec<String> = merged.into_iter().filter(|s| !s.is_empty()).collect(); + let mut seen = std::collections::HashSet::new(); + let unique: Vec<String> = merged + .into_iter() + .filter(|s| seen.insert(s.clone())) + .collect(); + + Ok(unique) } - pub fn set_default_command( + /// Finds a registered namespace by a name or an abbreviation. + /// + /// Throws NamespaceNotFoundException when namespace is incorrect or ambiguous. + pub fn find_namespace(&mut self, namespace: &str) -> anyhow::Result<String> { + let all_namespaces = self.get_namespaces()?; + // implode('[^:]*:', array_map('preg_quote', explode(':', $namespace))).'[^:]*' + let parts: Vec<String> = shirabe_php_shim::explode(":", namespace) + .into_iter() + .map(|p| shirabe_php_shim::preg_quote(&p, None)) + .collect(); + let expr = format!("{}{}", shirabe_php_shim::implode("[^:]*:", &parts), "[^:]*"); + let namespaces = shirabe_php_shim::preg_grep(&format!("{{^{}}}", expr), &all_namespaces); + + if namespaces.is_empty() { + let mut message = shirabe_php_shim::sprintf( + "There are no commands defined in the \"%s\" namespace.", + &[PhpMixed::from(namespace.to_string())], + ); + + let alternatives = self.find_alternatives(namespace, &all_namespaces); + if !alternatives.is_empty() { + if alternatives.len() == 1 { + message.push_str("\n\nDid you mean this?\n "); + } else { + message.push_str("\n\nDid you mean one of these?\n "); + } + + message.push_str(&shirabe_php_shim::implode("\n ", &alternatives)); + } + + return Err(NamespaceNotFoundException(CommandNotFoundException::new( + message, + alternatives, + 0, + )) + .into()); + } + + let exact = namespaces.iter().any(|n| n == namespace); + if namespaces.len() > 1 && !exact { + return Err(NamespaceNotFoundException(CommandNotFoundException::new( + shirabe_php_shim::sprintf( + "The namespace \"%s\" is ambiguous.\nDid you mean one of these?\n%s.", + &[ + PhpMixed::from(namespace.to_string()), + PhpMixed::from(self.get_abbreviation_suggestions(&namespaces)), + ], + ), + namespaces.clone(), + 0, + )) + .into()); + } + + // $exact ? $namespace : reset($namespaces) + if exact { + Ok(namespace.to_string()) + } else { + Ok(namespaces[0].clone()) + } + } + + /// Finds a command by name or alias. + /// + /// Contrary to get, this command tries to find the best match if you give it an + /// abbreviation of a name or alias. + /// + /// Throws CommandNotFoundException when command name is incorrect or ambiguous. + pub fn find(&mut self, name: &str) -> anyhow::Result<Rc<RefCell<dyn Command>>> { + self.init()?; + + let mut aliases: IndexMap<String, String> = IndexMap::new(); + + let commands_snapshot: Vec<Rc<RefCell<dyn Command>>> = + self.commands.values().cloned().collect(); + for command in &commands_snapshot { + for alias in command.borrow().get_aliases() { + if !self.has(&alias) { + self.commands.insert(alias, command.clone()); + } + } + } + + if self.has(name) { + return self.get(name); + } + + // $allCommands = commandLoader ? array_merge(loader->getNames(), array_keys(commands)) : array_keys(commands) + let all_commands: Vec<String> = match &self.command_loader { + Some(command_loader) => { + let mut all = command_loader.get_names(); + all.extend(self.commands.keys().cloned()); + all + } + None => self.commands.keys().cloned().collect(), + }; + + let parts: Vec<String> = shirabe_php_shim::explode(":", name) + .into_iter() + .map(|p| shirabe_php_shim::preg_quote(&p, None)) + .collect(); + let expr = format!("{}{}", shirabe_php_shim::implode("[^:]*:", &parts), "[^:]*"); + let mut commands = shirabe_php_shim::preg_grep(&format!("{{^{}}}", expr), &all_commands); + + if commands.is_empty() { + commands = shirabe_php_shim::preg_grep(&format!("{{^{}}}i", expr), &all_commands); + } + + // if no commands matched or we just matched namespaces + if commands.is_empty() + || shirabe_php_shim::preg_grep(&format!("{{^{}$}}i", expr), &commands).len() < 1 + { + if let Some(pos) = shirabe_php_shim::strrpos(name, ":") { + // check if a namespace exists and contains commands + self.find_namespace(&name[..pos as usize])?; + } + + let mut message = shirabe_php_shim::sprintf( + "Command \"%s\" is not defined.", + &[PhpMixed::from(name.to_string())], + ); + + let mut alternatives = self.find_alternatives(name, &all_commands); + if !alternatives.is_empty() { + // remove hidden commands + let mut filtered: Vec<String> = Vec::new(); + for alt in alternatives { + if !self.get(&alt)?.borrow().is_hidden() { + filtered.push(alt); + } + } + alternatives = filtered; + + if alternatives.len() == 1 { + message.push_str("\n\nDid you mean this?\n "); + } else { + message.push_str("\n\nDid you mean one of these?\n "); + } + message.push_str(&shirabe_php_shim::implode("\n ", &alternatives)); + } + + return Err(CommandNotFoundException::new(message, alternatives, 0).into()); + } + + // filter out aliases for commands which are already on the list + if commands.len() > 1 { + // $commandList = commandLoader ? array_merge(array_flip(loader->getNames()), commands) : commands + // TODO(review): $commandList mixes flipped loader names (string => int) with Command + // instances; this heterogeneous PHP array needs a typed representation. The alias + // de-duplication and the loader->get() lazy materialization are left to design. + let mut command_list: IndexMap<String, Rc<RefCell<dyn Command>>> = + self.commands.clone(); + + let commands_clone = commands.clone(); + let mut new_commands: Vec<String> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for name_or_alias in commands { + if !command_list.contains_key(&name_or_alias) { + let loaded = self.command_loader.as_ref().unwrap().get(&name_or_alias); + let _ = loaded; + command_list.insert( + name_or_alias.clone(), + todo!("Rc<RefCell<dyn Command>> from command_loader.get(name_or_alias)"), + ); + } + + let command_name = command_list[&name_or_alias] + .borrow() + .get_name() + .unwrap_or_default(); + + aliases.insert(name_or_alias.clone(), command_name.clone()); + + let keep = command_name == name_or_alias || !commands_clone.contains(&command_name); + if keep && seen.insert(name_or_alias.clone()) { + new_commands.push(name_or_alias); + } + } + commands = new_commands; + + if commands.len() > 1 { + let usable_width = self.terminal.get_width() - 10; + let abbrevs: Vec<String> = commands.clone(); + let mut max_len: i64 = 0; + for abbrev in &abbrevs { + max_len = std::cmp::max(Helper::width(abbrev), max_len); + } + let mut formatted_abbrevs: Vec<PhpMixed> = Vec::new(); + for cmd in commands.clone() { + if command_list[&cmd].borrow().is_hidden() { + // unset($commands[array_search($cmd, $commands)]) + if let Some(idx) = commands.iter().position(|c| *c == cmd) { + commands.remove(idx); + } + formatted_abbrevs.push(PhpMixed::Bool(false)); + continue; + } + + let abbrev = format!( + "{} {}", + shirabe_php_shim::str_pad( + &cmd, + max_len as usize, + " ", + shirabe_php_shim::STR_PAD_LEFT + ), + command_list[&cmd].borrow().get_description() + ); + + if Helper::width(&abbrev) > usable_width { + formatted_abbrevs.push(PhpMixed::from(format!( + "{}...", + Helper::substr(&abbrev, 0, Some(usable_width - 3)) + ))); + } else { + formatted_abbrevs.push(PhpMixed::from(abbrev)); + } + } + + if commands.len() > 1 { + let filtered: Vec<String> = formatted_abbrevs + .iter() + .filter(|a| shirabe_php_shim::php_truthy(a)) + .map(|a| shirabe_php_shim::php_to_string(a)) + .collect(); + let suggestions = self.get_abbreviation_suggestions(&filtered); + + return Err(CommandNotFoundException::new( + shirabe_php_shim::sprintf( + "Command \"%s\" is ambiguous.\nDid you mean one of these?\n%s.", + &[ + PhpMixed::from(name.to_string()), + PhpMixed::from(suggestions), + ], + ), + commands.clone(), + 0, + ) + .into()); + } + } + } + + // $command = $this->get(reset($commands)); + let command = self.get(&commands[0])?; + + if command.borrow().is_hidden() { + return Err(CommandNotFoundException::new( + shirabe_php_shim::sprintf( + "The command \"%s\" does not exist.", + &[PhpMixed::from(name.to_string())], + ), + Vec::new(), + 0, + ) + .into()); + } + + Ok(command) + } + + /// Gets the commands (registered in the given namespace if provided). + /// + /// The array keys are the full names and the values the command instances. + pub fn all( &mut self, - _command_name: &str, - _is_single_command: bool, - ) -> &mut Self { - todo!() + namespace: Option<&str>, + ) -> anyhow::Result<IndexMap<String, Rc<RefCell<dyn Command>>>> { + self.init()?; + + if namespace.is_none() { + if self.command_loader.is_none() { + return Ok(self.commands.clone()); + } + + let mut commands = self.commands.clone(); + let names = self.command_loader.as_ref().unwrap().get_names(); + for name in names { + if !commands.contains_key(&name) && self.has(&name) { + commands.insert(name.clone(), self.get(&name)?); + } + } + + return Ok(commands); + } + + let namespace = namespace.unwrap(); + let mut commands: IndexMap<String, Rc<RefCell<dyn Command>>> = IndexMap::new(); + let entries: Vec<(String, Rc<RefCell<dyn Command>>)> = self + .commands + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + for (name, command) in entries { + if namespace + == self.extract_namespace( + &name, + Some(shirabe_php_shim::substr_count(namespace, ":") + 1), + ) + { + commands.insert(name, command); + } + } + + if self.command_loader.is_some() { + let names = self.command_loader.as_ref().unwrap().get_names(); + for name in names { + if !commands.contains_key(&name) + && namespace + == self.extract_namespace( + &name, + Some(shirabe_php_shim::substr_count(namespace, ":") + 1), + ) + && self.has(&name) + { + commands.insert(name.clone(), self.get(&name)?); + } + } + } + + Ok(commands) } - pub fn is_single_command(&self) -> bool { - todo!() + /// Returns an array of possible abbreviations given a set of names. + pub fn get_abbreviations(names: Vec<String>) -> IndexMap<String, Vec<String>> { + let mut abbrevs: IndexMap<String, Vec<String>> = IndexMap::new(); + for name in names { + let mut len = shirabe_php_shim::strlen(&name); + while len > 0 { + let abbrev = shirabe_php_shim::substr(&name, 0, Some(len)); + abbrevs.entry(abbrev).or_default().push(name.clone()); + len -= 1; + } + } + + abbrevs } - pub fn render_throwable( - &self, - _exception: &anyhow::Error, - _output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, - ) { - todo!() + pub fn render_throwable(&self, e: &anyhow::Error, output: Rc<RefCell<dyn OutputInterface>>) { + output + .borrow() + .writeln(&["".to_string()], output_interface::VERBOSITY_QUIET); + + self.do_render_throwable(e, output.clone()); + + if let Some(running_command) = &self.running_command { + output.borrow().writeln( + &[shirabe_php_shim::sprintf( + "<info>%s</info>", + &[PhpMixed::from( + OutputFormatter::escape(&shirabe_php_shim::sprintf( + &running_command.borrow_mut().get_synopsis(false), + &[PhpMixed::from(self.get_name())], + )) + .unwrap(), + )], + )], + output_interface::VERBOSITY_QUIET, + ); + output + .borrow() + .writeln(&["".to_string()], output_interface::VERBOSITY_QUIET); + } } - pub fn set_catch_errors(&mut self, _catch_errors: bool) { - todo!() + pub fn do_render_throwable(&self, e: &anyhow::Error, output: Rc<RefCell<dyn OutputInterface>>) { + // do { ... } while ($e = $e->getPrevious()); + // TODO(review): PHP walks the exception chain via getPrevious() and reads getMessage(), + // getCode(), getFile(), getLine(), getTrace(). anyhow::Error exposes a source() chain but + // not file/line/trace; faithful rendering of the trace needs a Throwable-equivalent. + let _ = output; + let _ = e; + todo!("render exception chain (getMessage/getCode/getFile/getLine/getTrace/getPrevious)") } - pub fn has(&self, _name: &str) -> bool { - todo!() + /// Configures the input and output instances based on the user arguments and options. + pub fn configure_io( + &self, + input: &Rc<RefCell<dyn InputInterface>>, + output: &Rc<RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<()> { + if input.borrow().has_parameter_option( + PhpMixed::from(vec![PhpMixed::from("--ansi".to_string())]), + true, + ) { + output.borrow().set_decorated(true); + } else if input.borrow().has_parameter_option( + PhpMixed::from(vec![PhpMixed::from("--no-ansi".to_string())]), + true, + ) { + output.borrow().set_decorated(false); + } + + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--no-interaction".to_string()), + PhpMixed::from("-n".to_string()), + ]), + true, + ) { + input.borrow_mut().set_interactive(false); + } + + let mut shell_verbosity = shirabe_php_shim::getenv("SHELL_VERBOSITY").unwrap_or_default(); + let shell_verbosity_int: i64 = shell_verbosity.parse().unwrap_or(0); + let mut shell_verbosity: i64 = shell_verbosity_int; + match shell_verbosity_int { + -1 => { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_QUIET); + } + 1 => { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_VERBOSE); + } + 2 => { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_VERY_VERBOSE); + } + 3 => { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_DEBUG); + } + _ => { + shell_verbosity = 0; + } + } + + if input.borrow().has_parameter_option( + PhpMixed::from(vec![ + PhpMixed::from("--quiet".to_string()), + PhpMixed::from("-q".to_string()), + ]), + true, + ) { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_QUIET); + shell_verbosity = -1; + } else if input + .borrow() + .has_parameter_option(PhpMixed::from("-vvv".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose=3".to_string()), true) + || input.borrow().get_parameter_option( + PhpMixed::from("--verbose".to_string()), + PhpMixed::Bool(false), + true, + ) == PhpMixed::from(3i64) + { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_DEBUG); + shell_verbosity = 3; + } else if input + .borrow() + .has_parameter_option(PhpMixed::from("-vv".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose=2".to_string()), true) + || input.borrow().get_parameter_option( + PhpMixed::from("--verbose".to_string()), + PhpMixed::Bool(false), + true, + ) == PhpMixed::from(2i64) + { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_VERY_VERBOSE); + shell_verbosity = 2; + } else if input + .borrow() + .has_parameter_option(PhpMixed::from("-v".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose=1".to_string()), true) + || input + .borrow() + .has_parameter_option(PhpMixed::from("--verbose".to_string()), true) + || shirabe_php_shim::php_truthy(&input.borrow().get_parameter_option( + PhpMixed::from("--verbose".to_string()), + PhpMixed::Bool(false), + true, + )) + { + output + .borrow() + .set_verbosity(output_interface::VERBOSITY_VERBOSE); + shell_verbosity = 1; + } + + if shell_verbosity == -1 { + input.borrow_mut().set_interactive(false); + } + + if shirabe_php_shim::function_exists("putenv") { + shirabe_php_shim::putenv(&format!("SHELL_VERBOSITY={}", shell_verbosity)); + } + shirabe_php_shim::env_set("SHELL_VERBOSITY", shell_verbosity.to_string()); + shirabe_php_shim::server_set("SHELL_VERBOSITY", shell_verbosity.to_string()); + + let _ = &mut shell_verbosity; + + Ok(()) } - pub fn do_run( + /// Runs the current command. + /// + /// If an event dispatcher has been attached to the application, events are also + /// dispatched during the life-cycle of the command. + /// + /// Returns 0 if everything went fine, or an error code. + pub fn do_run_command( &mut self, - _input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, - _output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + command: Rc<RefCell<dyn Command>>, + input: Rc<RefCell<dyn InputInterface>>, + output: Rc<RefCell<dyn OutputInterface>>, ) -> anyhow::Result<i64> { - todo!() - } + if let Some(helper_set) = command.borrow().get_helper_set() { + for (_alias, helper) in helper_set.borrow().get_iterator() { + // if ($helper instanceof InputAwareInterface) $helper->setInput($input); + // TODO(review): downcasting a HelperInterface to InputAwareInterface is not + // expressible without a typed mechanism; needs design. + let _ = helper; + let _ = std::marker::PhantomData::<dyn InputAwareInterface>; + } + } - pub fn get_help(&self) -> String { - todo!() + if !self.signals_to_dispatch_event.is_empty() { + // $commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : [] + // TODO(review): Command is not a SignalableCommandInterface here; downcast needed. + let command_signals: Vec<i64> = Vec::new(); + let _ = std::marker::PhantomData::<dyn SignalableCommandInterface>; + + if !command_signals.is_empty() || self.dispatcher.is_some() { + if self.signal_registry.is_none() { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: "Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that \"pcntl_*\" functions are not disabled by your php.ini's \"disable_functions\" directive.".to_string(), + code: 0, + }) + .into()); + } + + if Terminal::has_stty_available() { + // TODO: registers SIGINT/SIGTERM handlers that restore the stty mode via + // shell_exec('stty ...'). pcntl signal handlers have no faithful Rust + // equivalent in Phase A. + let _stty_mode = shirabe_php_shim::shell_exec("stty -g"); + for _signal in [shirabe_php_shim::SIGINT, shirabe_php_shim::SIGTERM] { + todo!("register signal handler to restore stty mode"); + } + } + } + + if self.dispatcher.is_some() { + // TODO(plugin): for each signal, register a handler that dispatches ConsoleSignalEvent. + for &signal in &self.signals_to_dispatch_event.clone() { + let _event = ConsoleSignalEvent::new( + todo!("Box<dyn Command>"), + todo!("Box<dyn InputInterface>"), + todo!("Box<dyn OutputInterface>"), + signal, + ); + todo!("register signal handler dispatching ConsoleEvents::SIGNAL"); + } + } + + for _signal in command_signals { + // $this->signalRegistry->register($signal, [$command, 'handleSignal']); + todo!("register command->handle_signal as signal handler"); + } + } + + if self.dispatcher.is_none() { + return command.borrow_mut().run( + &mut *borrow_input_mut(&input), + &mut *borrow_output_mut(&output), + ); + } + + // bind before the console.command event, so the listeners have access to input options/arguments + match (|| -> anyhow::Result<()> { + command.borrow_mut().merge_application_definition(true); + input.borrow_mut().bind(command.borrow().get_definition())?; + Ok(()) + })() { + Ok(()) => {} + Err(e) => { + // ignore invalid options/arguments for now, to allow the event listeners to customize the InputDefinition + if !is_exception_interface(&e) { + return Err(e); + } + } + } + + // TODO(plugin): the whole dispatcher block below drives ConsoleCommandEvent / + // ConsoleErrorEvent / ConsoleTerminateEvent. The event objects require Box<dyn ...> + // wrappers for input/output/command and the dispatcher's dispatch() contract; their + // construction is left to the plugin/event design. + let _ = ConsoleCommandEvent::RETURN_CODE_DISABLED; + let _ = std::marker::PhantomData::<( + ConsoleCommandEvent, + ConsoleErrorEvent, + ConsoleTerminateEvent, + )>; + todo!("dispatcher-driven command run (console.command / console.error / console.terminate)") } - pub fn are_exceptions_caught(&self) -> bool { - todo!() + /// Gets the name of the command based on input. + pub fn get_command_name(&self, input: &dyn InputInterface) -> Option<String> { + if self.single_command { + Some(self.default_command.clone()) + } else { + input.get_first_argument() + } } + /// Gets the default input definition. pub fn get_default_input_definition(&self) -> InputDefinition { - todo!() + use crate::symfony::console::input::input_definition::DefinitionItem; + InputDefinition::new(vec![ + DefinitionItem::InputArgument( + InputArgument::new( + "command".to_string(), + Some(InputArgument::REQUIRED), + "The command to execute".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--help", + PhpMixed::from("-h".to_string()), + Some(InputOption::VALUE_NONE), + format!( + "Display help for the given command. When no command is given display help for the <info>{}</info> command", + self.default_command + ), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--quiet", + PhpMixed::from("-q".to_string()), + Some(InputOption::VALUE_NONE), + "Do not output any message".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--verbose", + PhpMixed::from("-v|vv|vvv".to_string()), + Some(InputOption::VALUE_NONE), + "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--version", + PhpMixed::from("-V".to_string()), + Some(InputOption::VALUE_NONE), + "Display this application version".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--ansi", + PhpMixed::from("".to_string()), + Some(InputOption::VALUE_NEGATABLE), + "Force (or disable --no-ansi) ANSI output".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + DefinitionItem::InputOption( + InputOption::new( + "--no-interaction", + PhpMixed::from("-n".to_string()), + Some(InputOption::VALUE_NONE), + "Do not ask any interactive question".to_string(), + PhpMixed::Null, + ) + .unwrap(), + ), + ]) + .unwrap() } - pub fn get_default_commands(&self) -> Vec<PhpMixed> { - todo!() + /// Gets the default commands that should always be available. + pub fn get_default_commands(&self) -> Vec<Rc<RefCell<dyn Command>>> { + // return [new HelpCommand(), new ListCommand(), new CompleteCommand(), new DumpCompletionCommand()]; + // TODO(review): HelpCommand/ListCommand/CompleteCommand/DumpCompletionCommand are ported as + // distinct structs (not subtypes of the concrete `Command` struct), so they cannot populate + // a Vec<Rc<RefCell<dyn Command>>>. Reconciling Command subclassing with Rust requires the + // command-hierarchy design decision (see also `add`/`find`/LazyCommand handling). + let _ = std::marker::PhantomData::<( + HelpCommand, + ListCommand, + CompleteCommand, + DumpCompletionCommand, + )>; + todo!("construct default commands once Command-subclass representation is decided") } + + /// Gets the default helper set with the helpers that should always be available. + pub fn get_default_helper_set(&self) -> Rc<RefCell<HelperSet>> { + use crate::symfony::console::helper::helper_interface::HelperInterface; + let helper_set = Rc::new(RefCell::new(HelperSet::default())); + let helpers: IndexMap< + crate::symfony::console::helper::helper_set::HelperSetKey, + Rc<RefCell<dyn HelperInterface>>, + > = { + let mut m: IndexMap< + crate::symfony::console::helper::helper_set::HelperSetKey, + Rc<RefCell<dyn HelperInterface>>, + > = IndexMap::new(); + m.insert( + crate::symfony::console::helper::helper_set::HelperSetKey::Int(0), + Rc::new(RefCell::new(FormatterHelper::default())), + ); + m.insert( + crate::symfony::console::helper::helper_set::HelperSetKey::Int(1), + Rc::new(RefCell::new(DebugFormatterHelper::default())), + ); + m.insert( + crate::symfony::console::helper::helper_set::HelperSetKey::Int(2), + Rc::new(RefCell::new(ProcessHelper::default())), + ); + m.insert( + crate::symfony::console::helper::helper_set::HelperSetKey::Int(3), + Rc::new(RefCell::new(QuestionHelper::default())), + ); + m + }; + HelperSet::new(&helper_set, helpers); + helper_set + } + + /// Returns abbreviated suggestions in string format. + fn get_abbreviation_suggestions(&self, abbrevs: &[String]) -> String { + format!(" {}", shirabe_php_shim::implode("\n ", abbrevs)) + } + + /// Returns the namespace part of the command name. + /// + /// This method is not part of public API and should not be used directly. + pub fn extract_namespace(&self, name: &str, limit: Option<i64>) -> String { + // $parts = explode(':', $name, -1); + let parts = shirabe_php_shim::explode_limit(":", name, -1); + + // implode(':', null === $limit ? $parts : array_slice($parts, 0, $limit)) + match limit { + None => shirabe_php_shim::implode(":", &parts), + Some(limit) => { + let sliced: Vec<String> = parts.into_iter().take(limit.max(0) as usize).collect(); + shirabe_php_shim::implode(":", &sliced) + } + } + } + + /// Finds alternative of $name among $collection, if nothing is found in + /// $collection, try in $abbrevs. + fn find_alternatives(&self, name: &str, collection: &[String]) -> Vec<String> { + let threshold = 1e3; + let mut alternatives: IndexMap<String, f64> = IndexMap::new(); + + let mut collection_parts: IndexMap<String, Vec<String>> = IndexMap::new(); + for item in collection { + collection_parts.insert(item.clone(), shirabe_php_shim::explode(":", item)); + } + + for (i, subname) in shirabe_php_shim::explode(":", name).into_iter().enumerate() { + for (collection_name, parts) in &collection_parts { + let exists = alternatives.contains_key(collection_name); + if parts.get(i).is_none() && exists { + *alternatives.get_mut(collection_name).unwrap() += threshold; + continue; + } else if parts.get(i).is_none() { + continue; + } + + let lev = shirabe_php_shim::levenshtein(&subname, &parts[i]) as f64; + if lev <= shirabe_php_shim::strlen(&subname) as f64 / 3.0 + || (!subname.is_empty() && parts[i].contains(&subname)) + { + let v = if exists { + alternatives[collection_name] + lev + } else { + lev + }; + alternatives.insert(collection_name.clone(), v); + } else if exists { + *alternatives.get_mut(collection_name).unwrap() += threshold; + } + } + } + + for item in collection { + let lev = shirabe_php_shim::levenshtein(name, item) as f64; + if lev <= shirabe_php_shim::strlen(name) as f64 / 3.0 || item.contains(name) { + let v = if alternatives.contains_key(item) { + alternatives[item] - lev + } else { + lev + }; + alternatives.insert(item.clone(), v); + } + } + + // array_filter($alternatives, fn($lev) => $lev < 2 * $threshold) + alternatives.retain(|_, lev| *lev < 2.0 * threshold); + // ksort($alternatives, SORT_NATURAL | SORT_FLAG_CASE) + let mut keys: Vec<String> = alternatives.keys().cloned().collect(); + shirabe_php_shim::sort_natural_flag_case(&mut keys); + + keys + } + + /// Sets the default Command name. + pub fn set_default_command( + &mut self, + command_name: &str, + is_single_command: bool, + ) -> anyhow::Result<&mut Self> { + // $this->defaultCommand = explode('|', ltrim($commandName, '|'))[0]; + let trimmed = shirabe_php_shim::ltrim(command_name, Some("|")); + self.default_command = shirabe_php_shim::explode("|", &trimmed) + .into_iter() + .next() + .unwrap_or_default(); + + if is_single_command { + // Ensure the command exist + self.find(command_name)?; + + self.single_command = true; + } + + Ok(self) + } + + /// @internal + pub fn is_single_command(&self) -> bool { + self.single_command + } + + fn split_string_by_width(&self, string: &str, width: i64) -> Vec<String> { + // str_split is not suitable for multi-byte characters, we should use preg_split to get char array properly. + let encoding = match shirabe_php_shim::mb_detect_encoding(string, None, true) { + None => return shirabe_php_shim::str_split(string, width), + Some(encoding) => encoding, + }; + + let utf8_string = shirabe_php_shim::mb_convert_encoding(string.into(), "utf8", &encoding); + let mut lines: Vec<String> = Vec::new(); + let mut line = String::new(); + + let mut offset = 0i64; + let mut m: Vec<String> = Vec::new(); + while shirabe_php_shim::preg_match_offset(r"/.{1,10000}/u", &utf8_string, &mut m, 0, offset) + { + offset += shirabe_php_shim::strlen(&m[0]); + + for char in shirabe_php_shim::preg_split_chars(r"//u", &m[0]) { + // test if $char could be appended to current line + if shirabe_php_shim::mb_strwidth(&format!("{}{}", line, char), Some("utf8")) + <= width + { + line.push_str(&char); + continue; + } + // if not, push current line to array and make new line + lines.push(shirabe_php_shim::str_pad( + &line, + width as usize, + " ", + shirabe_php_shim::STR_PAD_LEFT, + )); + line = char; + } + } + + lines.push(if !lines.is_empty() { + shirabe_php_shim::str_pad(&line, width as usize, " ", shirabe_php_shim::STR_PAD_LEFT) + } else { + line.clone() + }); + + shirabe_php_shim::mb_convert_variables(&encoding, "utf8", &mut lines); + + lines + } + + /// Returns all namespaces of the command name. + fn extract_all_namespaces(&self, name: &str) -> Vec<String> { + // -1 as third argument is needed to skip the command short name when exploding + let parts = shirabe_php_shim::explode_limit(":", name, -1); + let mut namespaces: Vec<String> = Vec::new(); + + for part in parts { + if !namespaces.is_empty() { + let last = namespaces.last().unwrap().clone(); + namespaces.push(format!("{}:{}", last, part)); + } else { + namespaces.push(part); + } + } + + namespaces + } + + fn init(&mut self) -> anyhow::Result<()> { + if self.initialized { + return Ok(()); + } + self.initialized = true; + + for command in self.get_default_commands() { + self.add(command)?; + } + + Ok(()) + } +} + +impl ResetInterface for Application { + fn reset(&mut self) { + Application::reset(self) + } +} + +/// Helper mirroring PHP's `$e instanceof ExceptionInterface`. +fn is_exception_interface(e: &anyhow::Error) -> bool { + // anyhow::Error stores concrete error types; enumerate the console exceptions + // that implement ExceptionInterface (PHP's `$e instanceof ExceptionInterface`). + e.downcast_ref::<CommandNotFoundException>().is_some() + || e.downcast_ref::<NamespaceNotFoundException>().is_some() + || e.downcast_ref::<LogicException>().is_some() + || e.downcast_ref::<RuntimeException>().is_some() +} + +/// Helper mirroring PHP's `$e instanceof CommandNotFoundException`. +fn downcast_command_not_found(e: &anyhow::Error) -> Option<&CommandNotFoundException> { + if let Some(cnf) = e.downcast_ref::<CommandNotFoundException>() { + return Some(cnf); + } + e.downcast_ref::<NamespaceNotFoundException>().map(|n| &n.0) +} + +/// Helper mirroring PHP's `$e instanceof NamespaceNotFoundException`. +fn is_namespace_not_found(e: &anyhow::Error) -> bool { + e.downcast_ref::<NamespaceNotFoundException>().is_some() +} + +/// Borrows the shared input as a mutable `dyn InputInterface` for passing to +/// `Command::run`, which takes `&mut dyn InputInterface`. +fn borrow_input_mut( + input: &Rc<RefCell<dyn InputInterface>>, +) -> std::cell::RefMut<'_, dyn InputInterface> { + input.borrow_mut() +} + +/// Borrows the shared output as a mutable `dyn OutputInterface` for passing to +/// `Command::run`, which takes `&mut dyn OutputInterface`. +fn borrow_output_mut( + output: &Rc<RefCell<dyn OutputInterface>>, +) -> std::cell::RefMut<'_, dyn OutputInterface> { + output.borrow_mut() } diff --git a/crates/shirabe-external-packages/src/symfony/console/attribute/as_command.rs b/crates/shirabe-external-packages/src/symfony/console/attribute/as_command.rs new file mode 100644 index 0000000..c742fd5 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/attribute/as_command.rs @@ -0,0 +1,34 @@ +/// Service tag to autoconfigure commands. +/// +/// PHP attribute: #[\Attribute(\Attribute::TARGET_CLASS)] +#[derive(Debug)] +pub struct AsCommand { + pub name: String, + pub description: Option<String>, +} + +impl AsCommand { + pub fn new( + name: String, + description: Option<String>, + aliases: Vec<String>, + hidden: bool, + ) -> Self { + let mut this = Self { name, description }; + + if !hidden && aliases.is_empty() { + return this; + } + + let mut name: Vec<String> = this.name.split('|').map(|s| s.to_string()).collect(); + name.extend(aliases); + + if hidden && "" != name[0] { + name.insert(0, String::new()); + } + + this.name = name.join("|"); + + this + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/attribute/mod.rs b/crates/shirabe-external-packages/src/symfony/console/attribute/mod.rs new file mode 100644 index 0000000..241eb67 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/attribute/mod.rs @@ -0,0 +1,3 @@ +pub mod as_command; + +pub use as_command::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/color.rs b/crates/shirabe-external-packages/src/symfony/console/color.rs new file mode 100644 index 0000000..8dfce9e --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/color.rs @@ -0,0 +1,244 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use indexmap::IndexMap; + +const COLORS: [(&str, i64); 9] = [ + ("black", 0), + ("red", 1), + ("green", 2), + ("yellow", 3), + ("blue", 4), + ("magenta", 5), + ("cyan", 6), + ("white", 7), + ("default", 9), +]; + +const BRIGHT_COLORS: [(&str, i64); 8] = [ + ("gray", 0), + ("bright-red", 1), + ("bright-green", 2), + ("bright-yellow", 3), + ("bright-blue", 4), + ("bright-magenta", 5), + ("bright-cyan", 6), + ("bright-white", 7), +]; + +const AVAILABLE_OPTIONS: [(&str, (i64, i64)); 5] = [ + ("bold", (1, 22)), + ("underscore", (4, 24)), + ("blink", (5, 25)), + ("reverse", (7, 27)), + ("conceal", (8, 28)), +]; + +fn colors_get(name: &str) -> Option<i64> { + COLORS.iter().find(|(k, _)| *k == name).map(|(_, v)| *v) +} + +fn bright_colors_get(name: &str) -> Option<i64> { + BRIGHT_COLORS + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| *v) +} + +fn available_options_get(name: &str) -> Option<(i64, i64)> { + AVAILABLE_OPTIONS + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| *v) +} + +#[derive(Debug)] +pub struct Color { + foreground: String, + background: String, + // option name => ['set' => i64, 'unset' => i64] + options: IndexMap<String, (i64, i64)>, +} + +impl Color { + pub fn new( + foreground: &str, + background: &str, + options: &[String], + ) -> Result<Self, InvalidArgumentException> { + let mut this = Self { + foreground: Self::parse_color(foreground, false)?, + background: Self::parse_color(background, true)?, + options: IndexMap::new(), + }; + + for option in options { + let available = available_options_get(option); + if available.is_none() { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Invalid option specified: \"%s\". Expected one of (%s).", + &[ + option.clone().into(), + shirabe_php_shim::implode( + ", ", + &AVAILABLE_OPTIONS + .iter() + .map(|(k, _)| k.to_string()) + .collect::<Vec<String>>(), + ) + .into(), + ], + ), + code: 0, + }, + )); + } + + this.options.insert(option.clone(), available.unwrap()); + } + + Ok(this) + } + + pub fn apply(&self, text: &str) -> String { + format!("{}{}{}", self.set(), text, self.unset()) + } + + pub fn set(&self) -> String { + let mut set_codes: Vec<String> = Vec::new(); + if !self.foreground.is_empty() { + set_codes.push(self.foreground.clone()); + } + if !self.background.is_empty() { + set_codes.push(self.background.clone()); + } + for option in self.options.values() { + set_codes.push(option.0.to_string()); + } + if set_codes.is_empty() { + return String::new(); + } + + shirabe_php_shim::sprintf( + "\u{1b}[%sm", + &[shirabe_php_shim::implode(";", &set_codes).into()], + ) + } + + pub fn unset(&self) -> String { + let mut unset_codes: Vec<String> = Vec::new(); + if !self.foreground.is_empty() { + unset_codes.push("39".to_string()); + } + if !self.background.is_empty() { + unset_codes.push("49".to_string()); + } + for option in self.options.values() { + unset_codes.push(option.1.to_string()); + } + if unset_codes.is_empty() { + return String::new(); + } + + shirabe_php_shim::sprintf( + "\u{1b}[%sm", + &[shirabe_php_shim::implode(";", &unset_codes).into()], + ) + } + + fn parse_color(color: &str, background: bool) -> Result<String, InvalidArgumentException> { + if color.is_empty() { + return Ok(String::new()); + } + + if &color[0..1] == "#" { + let mut color = shirabe_php_shim::substr(color, 1, None); + + if shirabe_php_shim::strlen(&color) == 3 { + let c: Vec<char> = color.chars().collect(); + color = format!("{}{}{}{}{}{}", c[0], c[0], c[1], c[1], c[2], c[2]); + } + + if shirabe_php_shim::strlen(&color) != 6 { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Invalid \"%s\" color.", + &[color.clone().into()], + ), + code: 0, + }, + )); + } + + return Ok(format!( + "{}{}", + if background { "4" } else { "3" }, + Self::convert_hex_color_to_ansi(shirabe_php_shim::hexdec(&color)) + )); + } + + if let Some(code) = colors_get(color) { + return Ok(format!("{}{}", if background { "4" } else { "3" }, code)); + } + + if let Some(code) = bright_colors_get(color) { + return Ok(format!("{}{}", if background { "10" } else { "9" }, code)); + } + + let mut available: Vec<String> = COLORS.iter().map(|(k, _)| k.to_string()).collect(); + available.extend(BRIGHT_COLORS.iter().map(|(k, _)| k.to_string())); + Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Invalid \"%s\" color; expected one of (%s).", + &[ + color.into(), + shirabe_php_shim::implode(", ", &available).into(), + ], + ), + code: 0, + }, + )) + } + + fn convert_hex_color_to_ansi(color: i64) -> String { + let r = (color >> 16) & 255; + let g = (color >> 8) & 255; + let b = color & 255; + + // see https://github.com/termstandard/colors/ for more information about true color support + if shirabe_php_shim::getenv("COLORTERM").as_deref() != Some("truecolor") { + return Self::degrade_hex_color_to_ansi(r, g, b).to_string(); + } + + shirabe_php_shim::sprintf("8;2;%d;%d;%d", &[r.into(), g.into(), b.into()]) + } + + fn degrade_hex_color_to_ansi(r: i64, g: i64, b: i64) -> i64 { + if shirabe_php_shim::round(Self::get_saturation(r, g, b) as f64 / 50.0, 0) == 0.0 { + return 0; + } + + ((shirabe_php_shim::round(b as f64 / 255.0, 0) as i64) << 2) + | ((shirabe_php_shim::round(g as f64 / 255.0, 0) as i64) << 1) + | (shirabe_php_shim::round(r as f64 / 255.0, 0) as i64) + } + + fn get_saturation(r: i64, g: i64, b: i64) -> i64 { + let r = r as f64 / 255.0; + let g = g as f64 / 255.0; + let b = b as f64 / 255.0; + let v = r.max(g).max(b); + + let diff = v - r.min(g).min(b); + if diff == 0.0 { + return 0; + } + + // PHP: `(int) $diff * 100 / $v`. The `(int)` cast binds to `$diff` only (and + // since 0 <= $diff < 1 it is always 0), `/` is float division, and the function + // return type `int` truncates the float result. + ((diff as i64) as f64 * 100.0 / v) as i64 + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command/command.rs b/crates/shirabe-external-packages/src/symfony/console/command/command.rs index 32965cf..1af9c59 100644 --- a/crates/shirabe-external-packages/src/symfony/console/command/command.rs +++ b/crates/shirabe-external-packages/src/symfony/console/command/command.rs @@ -1,18 +1,812 @@ -/// Stub for \Symfony\Component\Console\Command\Command. -pub trait Command { +use crate::symfony::console::application::Application; +use crate::symfony::console::completion::completion_input::CompletionInput; +use crate::symfony::console::completion::completion_suggestions::CompletionSuggestions; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::{self, OutputInterface}; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; + +/// Base class for all commands. +/// +/// Phase B: the PHP `Command` class is split into the polymorphic `Command` trait +/// (defined below) and this concrete `BaseCommand` struct holding the base-class +/// state and behavior. Subclasses embed a `BaseCommand` and implement `Command`. +pub struct BaseCommand { + application: Option<Rc<RefCell<Application>>>, + name: Option<String>, + process_title: Option<String>, + aliases: Vec<String>, + definition: Option<InputDefinition>, + hidden: bool, + help: String, + description: String, + full_definition: Option<InputDefinition>, + ignore_validation_errors: bool, + // A callable(InputInterface, OutputInterface) -> i64. + code: Option<Box<dyn Fn(&mut dyn InputInterface, &mut dyn OutputInterface) -> PhpMixed>>, + synopsis: IndexMap<String, String>, + usages: Vec<String>, + helper_set: Option<Rc<RefCell<HelperSet>>>, +} + +impl BaseCommand { + // see https://tldp.org/LDP/abs/html/exitcodes.html + pub const SUCCESS: i64 = 0; + pub const FAILURE: i64 = 1; + pub const INVALID: i64 = 2; + + /// The default command name. + // NOTE: PHP `protected static $defaultName`; static late-binding property. + pub const DEFAULT_NAME: Option<&'static str> = None; + + /// The default command description. + // NOTE: PHP `protected static $defaultDescription`; static late-binding property. + pub const DEFAULT_DESCRIPTION: Option<&'static str> = None; + + pub fn get_default_name() -> Option<String> { + // TODO(review): PHP uses ReflectionClass to read the #[AsCommand] attribute + // and ReflectionProperty to check that `$defaultName` is declared on the late-static + // class itself (not inherited). Reflection-based late static binding cannot be + // reproduced in Phase A; human review needed for the porting strategy. + todo!() + } + + pub fn get_default_description() -> Option<String> { + // TODO(review): same Reflection/late-static-binding concern as get_default_name(). + todo!() + } + + /// `$name` is the name of the command; passing None means it must be set in configure(). + /// + /// Throws LogicException when the command name is empty. + pub fn __construct(name: Option<String>) -> anyhow::Result<Self> { + let mut this = BaseCommand { + application: None, + name: None, + process_title: None, + aliases: Vec::new(), + definition: Some(InputDefinition::new(Vec::new())?), + hidden: false, + help: String::new(), + description: String::new(), + full_definition: None, + ignore_validation_errors: false, + code: None, + synopsis: IndexMap::new(), + usages: Vec::new(), + helper_set: None, + }; + + let mut name = name; + if name.is_none() { + name = Self::get_default_name(); + if let Some(n) = name.clone() { + let mut aliases: Vec<String> = n.split('|').map(|s| s.to_string()).collect(); + + let first = if aliases.is_empty() { + None + } else { + Some(aliases.remove(0)) + }; + name = first; + if name.as_deref() == Some("") { + this.set_hidden(true); + name = if aliases.is_empty() { + None + } else { + Some(aliases.remove(0)) + }; + } + + this.set_aliases(aliases)?; + } + } + + if let Some(n) = name { + this.set_name(&n)?; + } + + if this.description.is_empty() { + this.set_description(&Self::get_default_description().unwrap_or_default()); + } + + this.configure(); + + Ok(this) + } + + /// Ignores validation errors. + /// + /// This is mainly useful for the help command. + pub fn ignore_validation_errors(&mut self) { + self.ignore_validation_errors = true; + } + + pub fn set_application(&mut self, application: Option<Rc<RefCell<Application>>>) { + self.application = application.clone(); + if let Some(application) = application { + self.set_helper_set(application.borrow_mut().get_helper_set()); + } else { + self.helper_set = None; + } + + self.full_definition = None; + } + + pub fn set_helper_set(&mut self, helper_set: Rc<RefCell<HelperSet>>) { + self.helper_set = Some(helper_set); + } + + /// Gets the helper set. + pub fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.helper_set.clone() + } + + /// Gets the application instance for this command. + pub fn get_application(&self) -> Option<Rc<RefCell<Application>>> { + self.application.clone() + } + + /// Checks whether the command is enabled or not in the current environment. + /// + /// Override this to check for x or y and return false if the command cannot + /// run properly under the current conditions. + pub fn is_enabled(&self) -> bool { + true + } + + /// Configures the current command. + pub fn configure(&mut self) {} + + /// Executes the current command. + /// + /// This method is not abstract because you can use this class + /// as a concrete class. In this case, instead of defining the + /// execute() method, you set the code to execute by passing + /// a Closure to the set_code() method. + /// + /// Returns 0 if everything went fine, or an exit code. + /// + /// Throws LogicException when this abstract method is not implemented. + pub fn execute( + &mut self, + _input: &mut dyn InputInterface, + _output: &mut dyn OutputInterface, + ) -> anyhow::Result<Result<i64, LogicException>> { + Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: "You must override the execute() method in the concrete command class." + .to_string(), + code: 0, + }))) + } + + /// Interacts with the user. + /// + /// This method is executed before the InputDefinition is validated. + /// This means that this is the only place where the command can + /// interactively ask for values of missing required arguments. + pub fn interact(&mut self, _input: &mut dyn InputInterface, _output: &mut dyn OutputInterface) { + } + + /// Initializes the command after the input has been bound and before the input + /// is validated. + /// + /// This is mainly useful when a lot of commands extends one main command + /// where some things need to be initialized based on the input arguments and options. + pub fn initialize( + &mut self, + _input: &mut dyn InputInterface, + _output: &mut dyn OutputInterface, + ) { + } + + /// Runs the command. + /// + /// The code to execute is either defined directly with the + /// set_code() method or by overriding the execute() method + /// in a sub-class. + /// + /// Returns the command exit code. + /// + /// Throws ExceptionInterface when input binding fails. Bypass this by calling ignore_validation_errors(). + pub fn run( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + // add the application arguments and options + self.merge_application_definition(true); + + // bind the input against the command specific arguments/options + match input.bind(self.get_definition()) { + Ok(()) => {} + Err(e) => { + if !self.ignore_validation_errors { + return Err(e); + } + } + } + + self.initialize(input, output); + + if let Some(process_title) = &self.process_title { + // TODO: PHP probes for cli_set_process_title / setproctitle availability. + if shirabe_php_shim::function_exists("cli_set_process_title") { + if !shirabe_php_shim::cli_set_process_title(process_title) { + if shirabe_php_shim::PHP_OS == "Darwin" { + output.writeln( + &["<comment>Running \"cli_set_process_title\" as an unprivileged user is not supported on MacOS.</comment>".to_string()], + output_interface::VERBOSITY_VERY_VERBOSE, + ); + } else { + shirabe_php_shim::cli_set_process_title(process_title); + } + } + } else if shirabe_php_shim::function_exists("setproctitle") { + shirabe_php_shim::setproctitle(process_title); + } else if output.get_verbosity() == output_interface::VERBOSITY_VERY_VERBOSE { + output.writeln( + &["<comment>Install the proctitle PECL to be able to change the process title.</comment>".to_string()], + output_interface::OUTPUT_NORMAL, + ); + } + } + + if input.is_interactive() { + self.interact(input, output); + } + + // The command name argument is often omitted when a command is executed directly with its run() method. + // It would fail the validation if we didn't make sure the command argument is present, + // since it's required by the application. + if input.has_argument("command") && matches!(input.get_argument("command")?, PhpMixed::Null) + { + input.set_argument("command", PhpMixed::from(self.get_name()))?; + } + + input.validate()?; + + let status_code: PhpMixed; + if let Some(code) = &self.code { + status_code = code(input, output); + } else { + let executed = self.execute(input, output)?; + let executed = match executed { + Ok(v) => v, + Err(e) => return Err(anyhow::Error::new(e)), + }; + status_code = PhpMixed::from(executed); + // PHP also raises \TypeError when execute() does not return int; in this + // strongly-typed port execute() already returns an int, so the check is moot. + } + + // is_numeric($statusCode) ? (int) $statusCode : 0 + Ok(shirabe_php_shim::is_numeric_to_int(&status_code)) + } + + /// Adds suggestions to `suggestions` for the current completion input (e.g. option or argument). + pub fn complete(&self, _input: &CompletionInput, _suggestions: &mut CompletionSuggestions) {} + + /// Sets the code to execute when running this command. + /// + /// If this method is used, it overrides the code defined + /// in the execute() method. + /// + /// `$code` is a callable(InputInterface, OutputInterface). + /// + /// Throws InvalidArgumentException. + pub fn set_code( + &mut self, + code: Box<dyn Fn(&mut dyn InputInterface, &mut dyn OutputInterface) -> PhpMixed>, + ) -> &mut Self { + // TODO: PHP rebinds an unbound Closure's $this to the command instance via + // ReflectionFunction/Closure::bind. Rust closures have no `$this` rebinding; + // the closure is stored as-is. + self.code = Some(code); + + self + } + + /// Merges the application definition with the command definition. + /// + /// This method is not part of public API and should not be used directly. + /// + /// `$mergeArgs` is whether to merge or not the Application definition arguments to Command definition arguments. + pub fn merge_application_definition(&mut self, merge_args: bool) { + let _application = match &self.application { + None => return, + Some(application) => application.clone(), + }; + + // TODO: InputDefinition stores options/arguments as `Rc<InputOption>` / + // `Rc<InputArgument>` but its setters (`set_options`/`set_arguments`) take owned + // `Vec<InputOption>` / `Vec<InputArgument>`. Merging the application and command + // definitions therefore requires an agreed-upon ownership model for the + // definition entries (Phase C). Left as todo!() pending that design. + let _ = merge_args; + todo!() + } + + /// Sets an array of argument and option instances. + /// + /// `$definition` is an array of argument and option instances or a definition instance. + pub fn set_definition(&mut self, definition: SetDefinitionArg) -> &mut Self { + match definition { + SetDefinitionArg::Definition(definition) => { + self.definition = Some(definition); + } + SetDefinitionArg::Array(definition) => { + let _ = self.definition.as_mut().unwrap().set_definition(definition); + } + } + + self.full_definition = None; + + self + } + + /// Gets the InputDefinition attached to this Command. + pub fn get_definition(&self) -> &InputDefinition { + match &self.full_definition { + Some(full_definition) => full_definition, + None => self.get_native_definition(), + } + } + + /// Gets the InputDefinition to be used to create representations of this Command. + /// + /// Can be overridden to provide the original command representation when it would otherwise + /// be changed by merging with the application InputDefinition. + /// + /// This method is not part of public API and should not be used directly. + pub fn get_native_definition(&self) -> &InputDefinition { + match &self.definition { + None => { + // TODO(review): PHP throws LogicException here, but get_native_definition() + // returns InputDefinition (no Result). In this port `definition` is set in + // the constructor, so None should not occur; treated as a programming error. + panic!( + "Command class is not correctly initialized. You probably forgot to call the parent constructor." + ); + } + Some(definition) => definition, + } + } + + /// Adds an argument. + /// + /// `$mode` is the argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL. + /// `$default` is the default value (for InputArgument::OPTIONAL mode only). + /// + /// Throws InvalidArgumentException when argument mode is not valid. + pub fn add_argument( + &mut self, + name: &str, + mode: Option<i64>, + description: &str, + default: PhpMixed, + ) -> anyhow::Result<&mut Self> { + self.definition + .as_mut() + .unwrap() + .add_argument(InputArgument::new( + name.to_string(), + mode, + description.to_string(), + default.clone(), + )?)?; + if self.full_definition.is_some() { + self.full_definition + .as_mut() + .unwrap() + .add_argument(InputArgument::new( + name.to_string(), + mode, + description.to_string(), + default, + )?)?; + } + + Ok(self) + } + + /// Adds an option. + /// + /// `$shortcut` is the shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts. + /// `$mode` is the option mode: One of the InputOption::VALUE_* constants. + /// `$default` is the default value (must be null for InputOption::VALUE_NONE). + /// + /// Throws InvalidArgumentException if option mode is invalid or incompatible. + pub fn add_option( + &mut self, + name: &str, + shortcut: PhpMixed, + mode: Option<i64>, + description: &str, + default: PhpMixed, + ) -> anyhow::Result<&mut Self> { + self.definition + .as_mut() + .unwrap() + .add_option(InputOption::new( + name, + shortcut.clone(), + mode, + description.to_string(), + default.clone(), + )?)?; + if self.full_definition.is_some() { + self.full_definition + .as_mut() + .unwrap() + .add_option(InputOption::new( + name, + shortcut, + mode, + description.to_string(), + default, + )?)?; + } + + Ok(self) + } + + /// Sets the name of the command. + /// + /// This method can set both the namespace and the name if + /// you separate them by a colon (:) + /// + /// command.set_name("foo:bar"); + /// + /// Throws InvalidArgumentException when the name is invalid. + pub fn set_name(&mut self, name: &str) -> anyhow::Result<&mut Self> { + if let Err(e) = self.validate_name(name)? { + return Err(e.into()); + } + + self.name = Some(name.to_string()); + + Ok(self) + } + + /// Sets the process title of the command. + /// + /// This feature should be used only when creating a long process command, + /// like a daemon. + pub fn set_process_title(&mut self, title: &str) -> &mut Self { + self.process_title = Some(title.to_string()); + + self + } + + /// Returns the command name. + pub fn get_name(&self) -> Option<String> { + self.name.clone() + } + + /// `$hidden` is whether or not the command should be hidden from the list of commands. + pub fn set_hidden(&mut self, hidden: bool) -> &mut Self { + self.hidden = hidden; + + self + } + + /// Returns whether the command should be publicly shown or not. + pub fn is_hidden(&self) -> bool { + self.hidden + } + + /// Sets the description for the command. + pub fn set_description(&mut self, description: &str) -> &mut Self { + self.description = description.to_string(); + + self + } + + /// Returns the description for the command. + pub fn get_description(&self) -> String { + self.description.clone() + } + + /// Sets the help for the command. + pub fn set_help(&mut self, help: &str) -> &mut Self { + self.help = help.to_string(); + + self + } + + /// Returns the help for the command. + pub fn get_help(&self) -> String { + self.help.clone() + } + + /// Returns the processed help for the command replacing the %command.name% and + /// %command.full_name% patterns with the real values dynamically. + pub fn get_processed_help(&self) -> String { + let name = self.name.clone(); + let is_single_command = match &self.application { + Some(application) => application.borrow().is_single_command(), + None => false, + }; + + let placeholders = [ + "%command.name%".to_string(), + "%command.full_name%".to_string(), + ]; + let php_self = shirabe_php_shim::server("PHP_SELF"); + let replacements = [ + name.clone().unwrap_or_default(), + if is_single_command { + php_self.clone() + } else { + format!("{} {}", php_self, name.unwrap_or_default()) + }, + ]; + + let help = self.get_help(); + let subject = if help.is_empty() { + self.get_description() + } else { + help + }; + + shirabe_php_shim::str_replace_array(&placeholders, &replacements, &subject) + } + + /// Sets the aliases for the command. + /// + /// `$aliases` is an array of aliases for the command. + /// + /// Throws InvalidArgumentException when an alias is invalid. + pub fn set_aliases(&mut self, aliases: Vec<String>) -> anyhow::Result<&mut Self> { + let mut list = Vec::new(); + + for alias in &aliases { + if let Err(e) = self.validate_name(alias)? { + return Err(e.into()); + } + list.push(alias.clone()); + } + + // PHP: `\is_array($aliases) ? $aliases : $list`. Here `aliases` is always an + // array (Vec), so the result is `aliases`; `list` mirrors the validation loop. + self.aliases = aliases; + + Ok(self) + } + + /// Returns the aliases for the command. + pub fn get_aliases(&self) -> Vec<String> { + self.aliases.clone() + } + + /// Returns the synopsis for the command. + /// + /// `$short` is whether to show the short version of the synopsis (with options folded) or not. + pub fn get_synopsis(&mut self, short: bool) -> String { + let key = if short { "short" } else { "long" }.to_string(); + + if !self.synopsis.contains_key(&key) { + let value = format!( + "{} {}", + self.name.clone().unwrap_or_default(), + self.definition.as_ref().unwrap().get_synopsis(short) + ) + .trim() + .to_string(); + self.synopsis.insert(key.clone(), value); + } + + self.synopsis[&key].clone() + } + + /// Add a command usage example, it'll be prefixed with the command name. + pub fn add_usage(&mut self, usage: &str) -> &mut Self { + let mut usage = usage.to_string(); + let name = self.name.clone().unwrap_or_default(); + if !usage.starts_with(&name) { + usage = format!("{} {}", name, usage); + } + + self.usages.push(usage); + + self + } + + /// Returns alternative usages of the command. + pub fn get_usages(&self) -> Vec<String> { + self.usages.clone() + } + + /// Gets a helper instance by name. + /// + /// Throws LogicException if no HelperSet is defined. + /// Throws InvalidArgumentException if the helper is not defined. + pub fn get_helper(&self, name: &str) -> anyhow::Result<Result<PhpMixed, LogicException>> { + let helper_set = match &self.helper_set { + None => { + return Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: format!( + "Cannot retrieve helper \"{}\" because there is no HelperSet defined. Did you forget to add your command to the application or to set the application on the command using the setApplication() method? You can also set the HelperSet directly using the setHelperSet() method.", + name + ), + code: 0, + }))); + } + Some(helper_set) => helper_set, + }; + + // TODO(review): HelperSet::get() returns `Rc<RefCell<dyn HelperInterface>>`, but + // Command::getHelper() is typed `mixed` (PhpMixed) here and PhpMixed cannot hold a + // helper instance. The helper return modelling needs a dedicated type (Phase C). + let _ = helper_set; + todo!() + } + + /// Validates a command name. + /// + /// It must be non-empty and parts can optionally be separated by ":". + /// + /// Throws InvalidArgumentException when the name is invalid. + fn validate_name(&self, name: &str) -> anyhow::Result<Result<(), InvalidArgumentException>> { + let mut matches: Vec<Option<String>> = Vec::new(); + if shirabe_php_shim::preg_match(r"/^[^\:]++(\:[^\:]++)*$/", name, &mut matches) == 0 { + return Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: format!("Command name \"{}\" is invalid.", name), + code: 0, + }, + ))); + } + + Ok(Ok(())) + } +} + +/// The argument of Command::set_definition(), which accepts either an array of +/// argument/option instances or an InputDefinition. +#[derive(Debug)] +pub enum SetDefinitionArg { + Array(Vec<crate::symfony::console::input::input_definition::DefinitionItem>), + Definition(InputDefinition), +} + +/// Polymorphic interface for all commands (PHP's `Command` base class as seen by +/// callers that hold a command of unknown concrete type). +/// +/// Phase B: default methods are `todo!()`; the concrete behavior lives on +/// `BaseCommand`'s inherent methods. Object-safe so `dyn Command` works. +pub trait Command: std::fmt::Debug + shirabe_php_shim::AsAny { + fn clone_box(&self) -> Box<dyn Command> { + todo!() + } + + fn configure(&mut self) { + todo!() + } + + fn run( + &mut self, + _input: &mut dyn InputInterface, + _output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + todo!() + } + + fn complete(&self, _input: &CompletionInput, _suggestions: &mut CompletionSuggestions) { + todo!() + } + + fn is_enabled(&self) -> bool { + todo!() + } + + fn set_application(&mut self, _application: Option<Rc<RefCell<Application>>>) { + todo!() + } + + fn get_application(&self) -> Option<Rc<RefCell<Application>>> { + todo!() + } + + fn set_helper_set(&mut self, _helper_set: Rc<RefCell<HelperSet>>) { + todo!() + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + todo!() + } + + fn merge_application_definition(&mut self, _merge_args: bool) { + todo!() + } + + fn get_definition(&self) -> &InputDefinition { + todo!() + } + + fn get_native_definition(&self) -> &InputDefinition { + todo!() + } + + fn set_name(&mut self, _name: &str) -> anyhow::Result<()> { + todo!() + } + fn get_name(&self) -> Option<String> { todo!() } - fn set_name(&mut self, _name: &str) { + fn set_hidden(&mut self, _hidden: bool) { todo!() } - fn get_description(&self) -> String { + fn is_hidden(&self) -> bool { todo!() } fn set_description(&mut self, _description: &str) { todo!() } + + fn get_description(&self) -> String { + todo!() + } + + fn set_help(&mut self, _help: &str) { + todo!() + } + + fn get_help(&self) -> String { + todo!() + } + + fn get_processed_help(&self) -> String { + todo!() + } + + fn set_aliases(&mut self, _aliases: Vec<String>) -> anyhow::Result<()> { + todo!() + } + + fn get_aliases(&self) -> Vec<String> { + todo!() + } + + fn get_synopsis(&mut self, _short: bool) -> String { + todo!() + } + + fn get_usages(&self) -> Vec<String> { + todo!() + } + + fn get_helper(&self, _name: &str) -> anyhow::Result<Result<PhpMixed, LogicException>> { + todo!() + } + + fn ignore_validation_errors(&mut self) { + todo!() + } +} + +impl Command for BaseCommand {} + +impl std::fmt::Debug for BaseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BaseCommand") + .field("name", &self.name) + .field("aliases", &self.aliases) + .field("hidden", &self.hidden) + .field("description", &self.description) + .finish_non_exhaustive() + } } diff --git a/crates/shirabe-external-packages/src/symfony/console/command/complete_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/complete_command.rs new file mode 100644 index 0000000..7a986ec --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command/complete_command.rs @@ -0,0 +1,503 @@ +use indexmap::IndexMap; +use shirabe_php_shim::AsAny; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +use crate::symfony::console::command::command::{BaseCommand, Command}; +use crate::symfony::console::command::lazy_command::LazyCommand; +use crate::symfony::console::completion::completion_input::CompletionInput; +use crate::symfony::console::completion::completion_suggestions::{ + CompletionSuggestions, StringOrSuggestion, +}; +use crate::symfony::console::completion::output::completion_output_interface::CompletionOutputInterface; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::{self, OutputInterface}; + +/// Responsible for providing the values to the shell completion. +#[derive(Debug)] +pub struct CompleteCommand { + inner: BaseCommand, + completion_outputs: IndexMap<String, PhpMixed>, + is_debug: bool, +} + +impl Deref for CompleteCommand { + type Target = BaseCommand; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for CompleteCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl CompleteCommand { + pub const DEFAULT_NAME: &'static str = "|_complete"; + pub const DEFAULT_DESCRIPTION: &'static str = + "Internal command to provide shell completion suggestions"; + + /// @param completion_outputs A list of additional completion outputs, with shell name as + /// key and FQCN as value + pub fn new(completion_outputs: IndexMap<String, PhpMixed>) -> anyhow::Result<Self> { + // must be set before the parent constructor, as the property value is used in configure() + let mut completion_outputs = completion_outputs; + // $completionOutputs + ['bash' => BashCompletionOutput::class] + completion_outputs + .entry("bash".to_string()) + .or_insert_with(|| { + PhpMixed::from( + "Symfony\\Component\\Console\\Completion\\Output\\BashCompletionOutput" + .to_string(), + ) + }); + + let this = Self { + inner: BaseCommand::__construct(None)?, + completion_outputs, + is_debug: false, + }; + + Ok(this) + } + + fn configure(&mut self) -> anyhow::Result<()> { + let shells = self + .completion_outputs + .keys() + .cloned() + .collect::<Vec<_>>() + .join("\", \""); + self.inner + .add_option( + "shell", + PhpMixed::from("s".to_string()), + Some(InputOption::VALUE_REQUIRED), + &format!("The shell type (\"{}\")", shells), + PhpMixed::Null, + )? + .add_option( + "input", + PhpMixed::from("i".to_string()), + Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + "An array of input tokens (e.g. COMP_WORDS or argv)", + PhpMixed::Null, + )? + .add_option( + "current", + PhpMixed::from("c".to_string()), + Some(InputOption::VALUE_REQUIRED), + "The index of the \"input\" array that the cursor is in (e.g. COMP_CWORD)", + PhpMixed::Null, + )? + .add_option( + "symfony", + PhpMixed::from("S".to_string()), + Some(InputOption::VALUE_REQUIRED), + "The version of the completion script", + PhpMixed::Null, + )?; + + Ok(()) + } + + fn initialize(&mut self, _input: &dyn InputInterface, _output: &dyn OutputInterface) { + self.is_debug = shirabe_php_shim::filter_var( + &shirabe_php_shim::getenv("SYMFONY_COMPLETION_DEBUG").unwrap_or_default(), + shirabe_php_shim::FILTER_VALIDATE_BOOLEAN, + ); + } + + fn execute( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + // try { ... } catch (\Throwable $e) { ...; if ($output->isDebug()) { throw $e; } return 2; } + let result: anyhow::Result<i64> = (|| { + // uncomment when a bugfix or BC break has been introduced in the shell completion scripts + // $version = $input->getOption('symfony'); + // if ($version && version_compare($version, 'x.y', '>=')) { + // $message = sprintf('Completion script version is not supported ("%s" given, ">=x.y" required).', $version); + // $this->log($message); + // $output->writeln($message.' Install the Symfony completion script again by using the "completion" command.'); + // return 126; + // } + + let shell = input.get_option("shell")?; + if !shell.to_bool() { + anyhow::bail!(shirabe_php_shim::RuntimeException { + message: "The \"--shell\" option must be set.".to_string(), + code: 0, + }); + } + + let completion_output = self + .completion_outputs + .get(&shell.to_string()) + .cloned() + .unwrap_or(PhpMixed::Bool(false)); + if !completion_output.to_bool() { + anyhow::bail!(shirabe_php_shim::RuntimeException { + message: format!( + "Shell completion is not supported for your shell: \"{}\" (supported: \"{}\").", + shell, + self.completion_outputs + .keys() + .cloned() + .collect::<Vec<_>>() + .join("\", \"") + ), + code: 0, + }); + } + + let mut completion_input = self.create_completion_input(input)?; + let mut suggestions = CompletionSuggestions::new(); + + self.log_many(vec![ + String::new(), + format!( + "<comment>{}</>", + shirabe_php_shim::date("Y-m-d H:i:s", None) + ), + "<info>Input:</> <comment>(\"|\" indicates the cursor position)</>".to_string(), + format!(" {}", completion_input.to_string()), + "<info>Command:</>".to_string(), + format!(" {}", shirabe_php_shim::server_argv().join(" ")), + "<info>Messages:</>".to_string(), + ]); + + let command = self.find_command(&completion_input, output); + match command { + None => { + self.log(" No command found, completing using the Application class."); + + let application = self.get_application().unwrap(); + application + .borrow_mut() + .complete(&completion_input, &mut suggestions)?; + } + Some(command) + if completion_input.must_suggest_argument_values_for("command") + && command.borrow().get_name().as_deref() + != Some(&completion_input.get_completion_value()) + && !command + .borrow() + .get_aliases() + .iter() + .any(|a| a == &completion_input.get_completion_value()) => + { + self.log(" No command found, completing using the Application class."); + + // expand shortcut names ("cache:cl<TAB>") into their full name ("cache:clear") + let mut values = vec![command.borrow().get_name()]; + values.extend(command.borrow().get_aliases().into_iter().map(Some)); + suggestions.suggest_values( + values + .into_iter() + .flatten() + .filter(|v| !v.is_empty()) + .map(StringOrSuggestion::String) + .collect(), + ); + } + Some(command) => { + command.borrow_mut().merge_application_definition(false); + completion_input.bind(command.borrow().get_definition())?; + + if CompletionInput::TYPE_OPTION_NAME == completion_input.get_completion_type() { + self.log(&format!( + " Completing option names for the <comment>{}</> command.", + get_class_of_command(&command) + )); + + suggestions.suggest_options(get_definition_options(&command)); + } else { + self.log_many(vec![ + format!( + " Completing using the <comment>{}</> class.", + get_class_of_command(&command) + ), + format!( + " Completing <comment>{}</> for <comment>{}</>", + completion_input.get_completion_type(), + completion_input.get_completion_name().unwrap_or_default() + ), + ]); + let compval = completion_input.get_completion_value(); + if !compval.is_empty() { + self.log(&format!(" Current value: <comment>{}</>", compval)); + } + + command + .borrow() + .complete(&completion_input, &mut suggestions); + } + } + } + + // $completionOutput = new $completionOutput(); + let completion_output: Box<dyn CompletionOutputInterface> = + instantiate_completion_output(&completion_output); + + self.log("<info>Suggestions:</>"); + let option_suggestions = suggestions.get_option_suggestions(); + if !option_suggestions.is_empty() { + self.log(&format!( + " --{}", + option_suggestions + .iter() + .map(|o| o.get_name()) + .collect::<Vec<_>>() + .join(" --") + )); + } else { + let value_suggestions: Vec<String> = suggestions + .get_value_suggestions() + .iter() + .map(|s| s.get_value()) + .collect(); + if !value_suggestions.is_empty() { + self.log(&format!(" {}", value_suggestions.join(" "))); + } else { + self.log(" <comment>No suggestions were provided</>"); + } + } + + completion_output.write(&suggestions, output); + + Ok(0) + })(); + + match result { + Ok(code) => Ok(code), + Err(e) => { + self.log_many(vec!["<error>Error!</error>".to_string(), format!("{}", e)]); + + if output.is_debug() { + return Err(e); + } + + Ok(2) + } + } + } + + fn create_completion_input( + &self, + input: &dyn InputInterface, + ) -> anyhow::Result<CompletionInput> { + let current_index = input.get_option("current")?; + if !current_index.to_bool() || !shirabe_php_shim::ctype_digit(¤t_index.to_string()) { + anyhow::bail!(shirabe_php_shim::RuntimeException { + message: "The \"--current\" option must be set and it must be an integer." + .to_string(), + code: 0, + }); + } + + let tokens: Vec<String> = match input.get_option("input")?.as_list() { + Some(list) => list.iter().map(|v| v.to_string()).collect(), + None => Vec::new(), + }; + let mut completion_input = CompletionInput::from_tokens( + tokens, + current_index.to_string().parse::<i64>().unwrap_or(0), + )?; + + // try { $completionInput->bind(...); } catch (ExceptionInterface $e) {} + let application = self.get_application().unwrap(); + let definition = application.borrow_mut().get_definition(); + let _ = completion_input.bind(&definition.borrow()); + + Ok(completion_input) + } + + fn find_command( + &self, + completion_input: &CompletionInput, + _output: &dyn OutputInterface, + ) -> Option<Rc<RefCell<dyn Command>>> { + // try { ... } catch (CommandNotFoundException $e) {} + let input_name = completion_input.get_first_argument()?; + + let application = self.get_application().unwrap(); + // CommandNotFoundException is caught and swallowed by returning None. + application.borrow_mut().find(&input_name).ok() + } + + fn log(&self, messages: &str) { + self.log_many(vec![messages.to_string()]); + } + + fn log_many(&self, messages: Vec<String>) { + if !self.is_debug { + return; + } + + let command_name = shirabe_php_shim::basename(&shirabe_php_shim::server_argv()[0]); + shirabe_php_shim::file_put_contents3( + &format!( + "{}/sf_{}.log", + shirabe_php_shim::sys_get_temp_dir(), + command_name + ), + &(messages.join(shirabe_php_shim::PHP_EOL) + shirabe_php_shim::PHP_EOL), + shirabe_php_shim::FILE_APPEND, + ); + } +} + +/// \get_class($command instanceof LazyCommand ? $command->getCommand() : $command) +fn get_class_of_command(command: &Rc<RefCell<dyn Command>>) -> String { + let borrowed = command.borrow(); + let _is_lazy = (*borrowed).as_any().downcast_ref::<LazyCommand>().is_some(); + // TODO: get_class() takes a PhpMixed but the command is a `dyn Command`; reflecting the + // concrete class name of a trait object requires a class-name hook on Command (Phase C). + todo!() +} + +/// $command->getDefinition()->getOptions() +fn get_definition_options(_command: &Rc<RefCell<dyn Command>>) -> Vec<InputOption> { + // TODO: InputDefinition::get_options() returns `&IndexMap<String, Rc<InputOption>>` but + // CompletionSuggestions::suggest_options() takes `Vec<InputOption>`; the option ownership + // model must be reconciled (Phase C). + todo!() +} + +/// new $completionOutput(); +fn instantiate_completion_output(_class: &PhpMixed) -> Box<dyn CompletionOutputInterface> { + todo!() +} + +impl Command for CompleteCommand { + fn configure(&mut self) { + let _ = CompleteCommand::configure(self); + } + + fn run( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + self.inner.run(input, output) + } + + fn is_enabled(&self) -> bool { + self.inner.is_enabled() + } + + fn set_application( + &mut self, + application: Option<Rc<RefCell<crate::symfony::console::application::Application>>>, + ) { + self.inner.set_application(application); + } + + fn get_application( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::application::Application>>> { + self.inner.get_application() + } + + fn set_helper_set( + &mut self, + helper_set: Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>, + ) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>> { + self.inner.get_helper_set() + } + + fn merge_application_definition(&mut self, merge_args: bool) { + self.inner.merge_application_definition(merge_args); + } + + fn get_definition(&self) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_definition() + } + + fn get_native_definition( + &self, + ) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_native_definition() + } + + fn set_name(&mut self, name: &str) -> anyhow::Result<()> { + self.inner.set_name(name)?; + Ok(()) + } + + fn get_name(&self) -> Option<String> { + self.inner.get_name() + } + + fn set_hidden(&mut self, hidden: bool) { + self.inner.set_hidden(hidden); + } + + fn is_hidden(&self) -> bool { + self.inner.is_hidden() + } + + fn set_description(&mut self, description: &str) { + self.inner.set_description(description); + } + + fn get_description(&self) -> String { + self.inner.get_description() + } + + fn set_help(&mut self, help: &str) { + self.inner.set_help(help); + } + + fn get_help(&self) -> String { + self.inner.get_help() + } + + fn get_processed_help(&self) -> String { + self.inner.get_processed_help() + } + + fn set_aliases(&mut self, aliases: Vec<String>) -> anyhow::Result<()> { + self.inner.set_aliases(aliases)?; + Ok(()) + } + + fn get_aliases(&self) -> Vec<String> { + self.inner.get_aliases() + } + + fn get_synopsis(&mut self, short: bool) -> String { + self.inner.get_synopsis(short) + } + + fn get_usages(&self) -> Vec<String> { + self.inner.get_usages() + } + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result< + Result<PhpMixed, crate::symfony::console::exception::logic_exception::LogicException>, + > { + self.inner.get_helper(name) + } + + fn ignore_validation_errors(&mut self) { + self.inner.ignore_validation_errors(); + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command/dump_completion_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/dump_completion_command.rs new file mode 100644 index 0000000..51287c7 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command/dump_completion_command.rs @@ -0,0 +1,337 @@ +use crate::symfony::console::command::command::{BaseCommand, Command}; +use crate::symfony::console::completion::completion_input::CompletionInput; +use crate::symfony::console::completion::completion_suggestions::{ + CompletionSuggestions, StringOrSuggestion, +}; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::{self, OutputInterface}; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +/// Dumps the completion script for the current shell. +#[derive(Debug)] +pub struct DumpCompletionCommand { + inner: BaseCommand, +} + +impl Deref for DumpCompletionCommand { + type Target = BaseCommand; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for DumpCompletionCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl DumpCompletionCommand { + pub const DEFAULT_NAME: &'static str = "completion"; + pub const DEFAULT_DESCRIPTION: &'static str = "Dump the shell completion script"; + + pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + if input.must_suggest_argument_values_for("shell") { + suggestions.suggest_values( + self.get_supported_shells() + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + } + + fn configure(&mut self) -> anyhow::Result<()> { + let full_command = shirabe_php_shim::server_php_self(); + let command_name = shirabe_php_shim::basename(&full_command); + // @realpath($fullCommand) ?: $fullCommand + let full_command = match shirabe_php_shim::realpath(&full_command) { + Some(p) if !p.is_empty() => p, + _ => full_command, + }; + + self.inner.set_help(&format!( + "The <info>%command.name%</> command dumps the shell completion script required\n\ + to use shell autocompletion (currently only bash completion is supported).\n\ + \n\ + <comment>Static installation\n\ + -------------------</>\n\ + \n\ + Dump the script to a global completion file and restart your shell:\n\ + \n\ + \x20\x20\x20\x20<info>%command.full_name% bash | sudo tee /etc/bash_completion.d/{command_name}</>\n\ + \n\ + Or dump the script to a local file and source it:\n\ + \n\ + \x20\x20\x20\x20<info>%command.full_name% bash > completion.sh</>\n\ + \n\ + \x20\x20\x20\x20<comment># source the file whenever you use the project</>\n\ + \x20\x20\x20\x20<info>source completion.sh</>\n\ + \n\ + \x20\x20\x20\x20<comment># or add this line at the end of your \"~/.bashrc\" file:</>\n\ + \x20\x20\x20\x20<info>source /path/to/completion.sh</>\n\ + \n\ + <comment>Dynamic installation\n\ + --------------------</>\n\ + \n\ + Add this to the end of your shell configuration file (e.g. <info>\"~/.bashrc\"</>):\n\ + \n\ + \x20\x20\x20\x20<info>eval \"$({full_command} completion bash)\"</>", + )) + .add_argument( + "shell", + Some(InputArgument::OPTIONAL), + "The shell type (e.g. \"bash\"), the value of the \"$SHELL\" env var will be used if this is not given", + PhpMixed::Null, + )? + .add_option( + "debug", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "Tail the completion debug log", + PhpMixed::Null, + )?; + + Ok(()) + } + + fn execute( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + let command_name = shirabe_php_shim::basename(&shirabe_php_shim::server_argv()[0]); + + if input.get_option("debug")?.to_bool() { + self.tail_debug_log(&command_name, output); + + return Ok(0); + } + + let shell = match input.get_argument("shell")?.as_string() { + Some(s) => s.to_string(), + None => Self::guess_shell(), + }; + let completion_file = format!( + "{}/../Resources/completion.{}", + shirabe_php_shim::dir(), + shell + ); + if !shirabe_php_shim::file_exists(&completion_file) { + let supported_shells = self.get_supported_shells(); + + // TODO: PHP does `$output instanceof ConsoleOutputInterface ? $output->getErrorOutput() + // : $output`. There is no way to test trait membership through `&dyn OutputInterface` + // here; OutputInterface would need a downcast hook (Phase C). Writing to `output`. + if !shell.is_empty() { + output.writeln( + &[format!( + "<error>Detected shell \"{}\", which is not supported by Symfony shell completion (supported shells: \"{}\").</>", + shell, + supported_shells.join("\", \"") + )], + output_interface::OUTPUT_NORMAL, + ); + } else { + output.writeln( + &[format!( + "<error>Shell not detected, Symfony shell completion only supports \"{}\").</>", + supported_shells.join("\", \"") + )], + output_interface::OUTPUT_NORMAL, + ); + } + + return Ok(2); + } + + let application = self.get_application().unwrap(); + let version = application.borrow().get_version(); + output.write( + &[shirabe_php_shim::str_replace_arrays( + &[ + "{{ COMMAND_NAME }}".to_string(), + "{{ VERSION }}".to_string(), + ], + &[command_name, version], + &shirabe_php_shim::file_get_contents(&completion_file).unwrap_or_default(), + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + Ok(0) + } + + fn guess_shell() -> String { + shirabe_php_shim::basename(&shirabe_php_shim::server_shell().unwrap_or_default()) + } + + fn tail_debug_log(&self, command_name: &str, _output: &dyn OutputInterface) { + let debug_file = format!( + "{}/sf_{}.log", + shirabe_php_shim::sys_get_temp_dir(), + command_name + ); + if !shirabe_php_shim::file_exists(&debug_file) { + shirabe_php_shim::touch(&debug_file); + } + // TODO: Process::run() expects a `'static` callback, but the PHP closure captures + // `$output` by reference and writes each line to it. Bridging the borrowed `output` + // into a `'static` callback requires shared ownership of the output (Phase C). + todo!() + } + + fn get_supported_shells(&self) -> Vec<String> { + let mut shells = vec![]; + + // foreach (new \DirectoryIterator(__DIR__.'/../Resources/') as $file) + for file in shirabe_php_shim::directory_iterator(&format!( + "{}/../Resources/", + shirabe_php_shim::dir() + )) { + if shirabe_php_shim::str_starts_with(&file.get_basename(), "completion.") + && file.is_file() + { + shells.push(file.get_extension()); + } + } + + shells + } +} + +impl Command for DumpCompletionCommand { + fn configure(&mut self) { + let _ = DumpCompletionCommand::configure(self); + } + + fn run( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + self.inner.run(input, output) + } + + fn complete(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + self.complete_impl(input, suggestions); + } + + fn is_enabled(&self) -> bool { + self.inner.is_enabled() + } + + fn set_application( + &mut self, + application: Option<Rc<RefCell<crate::symfony::console::application::Application>>>, + ) { + self.inner.set_application(application); + } + + fn get_application( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::application::Application>>> { + self.inner.get_application() + } + + fn set_helper_set( + &mut self, + helper_set: Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>, + ) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>> { + self.inner.get_helper_set() + } + + fn merge_application_definition(&mut self, merge_args: bool) { + self.inner.merge_application_definition(merge_args); + } + + fn get_definition(&self) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_definition() + } + + fn get_native_definition( + &self, + ) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_native_definition() + } + + fn set_name(&mut self, name: &str) -> anyhow::Result<()> { + self.inner.set_name(name)?; + Ok(()) + } + + fn get_name(&self) -> Option<String> { + self.inner.get_name() + } + + fn set_hidden(&mut self, hidden: bool) { + self.inner.set_hidden(hidden); + } + + fn is_hidden(&self) -> bool { + self.inner.is_hidden() + } + + fn set_description(&mut self, description: &str) { + self.inner.set_description(description); + } + + fn get_description(&self) -> String { + self.inner.get_description() + } + + fn set_help(&mut self, help: &str) { + self.inner.set_help(help); + } + + fn get_help(&self) -> String { + self.inner.get_help() + } + + fn get_processed_help(&self) -> String { + self.inner.get_processed_help() + } + + fn set_aliases(&mut self, aliases: Vec<String>) -> anyhow::Result<()> { + self.inner.set_aliases(aliases)?; + Ok(()) + } + + fn get_aliases(&self) -> Vec<String> { + self.inner.get_aliases() + } + + fn get_synopsis(&mut self, short: bool) -> String { + self.inner.get_synopsis(short) + } + + fn get_usages(&self) -> Vec<String> { + self.inner.get_usages() + } + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result< + Result<PhpMixed, crate::symfony::console::exception::logic_exception::LogicException>, + > { + self.inner.get_helper(name) + } + + fn ignore_validation_errors(&mut self) { + self.inner.ignore_validation_errors(); + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command/help_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/help_command.rs new file mode 100644 index 0000000..0a26931 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command/help_command.rs @@ -0,0 +1,269 @@ +use crate::symfony::console::command::command::{BaseCommand, Command, SetDefinitionArg}; +use crate::symfony::console::completion::completion_input::CompletionInput; +use crate::symfony::console::completion::completion_suggestions::{ + CompletionSuggestions, StringOrSuggestion, +}; +use crate::symfony::console::descriptor::application_description::ApplicationDescription; +use crate::symfony::console::helper::descriptor_helper::DescriptorHelper; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::DefinitionItem; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::OutputInterface; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +/// HelpCommand displays the help for a given command. +#[derive(Debug)] +pub struct HelpCommand { + inner: BaseCommand, + command: Option<Rc<RefCell<dyn Command>>>, +} + +impl Deref for HelpCommand { + type Target = BaseCommand; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for HelpCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl HelpCommand { + fn configure(&mut self) -> anyhow::Result<()> { + self.inner.ignore_validation_errors(); + + self.inner + .set_name("help")? + .set_definition(SetDefinitionArg::Array(vec![ + DefinitionItem::InputArgument(InputArgument::new( + "command_name".to_string(), + Some(InputArgument::OPTIONAL), + "The command name".to_string(), + PhpMixed::from("help".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "format", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED), + "The output format (txt, xml, json, or md)".to_string(), + PhpMixed::from("txt".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "raw", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To output raw command help".to_string(), + PhpMixed::Null, + )?), + ])) + .set_description("Display help for a command") + .set_help( + "The <info>%command.name%</info> command displays help for a given command:\n\ + \n\ + \x20\x20<info>%command.full_name% list</info>\n\ + \n\ + You can also output the help in other formats by using the <comment>--format</comment> option:\n\ + \n\ + \x20\x20<info>%command.full_name% --format=xml list</info>\n\ + \n\ + To display the list of available commands, please use the <info>list</info> command.", + ); + + Ok(()) + } + + pub fn set_command(&mut self, command: Rc<RefCell<dyn Command>>) { + self.command = Some(command); + } + + fn execute( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + if self.command.is_none() { + let application = self.get_application().unwrap(); + let command_name = input.get_argument("command_name")?.to_string(); + self.command = Some(application.borrow_mut().find(&command_name)?); + } + + let helper = DescriptorHelper::new(); + // TODO: DescriptorHelper::describe2 takes the described object as Option<PhpMixed>, + // but PhpMixed cannot hold a Command. The Command/Application object mixing for + // describe needs a dedicated type (Phase C). + let object: Option<PhpMixed> = todo!(); + let mut options = indexmap::IndexMap::new(); + options.insert("format".to_string(), input.get_option("format")?); + options.insert("raw_text".to_string(), input.get_option("raw")?); + let _ = helper.describe2(output, object, options); + + self.command = None; + + Ok(0) + } + + pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + if input.must_suggest_argument_values_for("command_name") { + let application = self.get_application().unwrap(); + let mut descriptor = ApplicationDescription::new(application, None, false); + suggestions.suggest_values( + descriptor + .get_commands() + .keys() + .cloned() + .map(StringOrSuggestion::String) + .collect(), + ); + + return; + } + + if input.must_suggest_option_values_for("format") { + let helper = DescriptorHelper::new(); + suggestions.suggest_values( + helper + .get_formats() + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + } +} + +impl Command for HelpCommand { + fn configure(&mut self) { + let _ = HelpCommand::configure(self); + } + + fn run( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + self.inner.run(input, output) + } + + fn complete(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + self.complete_impl(input, suggestions); + } + + fn is_enabled(&self) -> bool { + self.inner.is_enabled() + } + + fn set_application( + &mut self, + application: Option<Rc<RefCell<crate::symfony::console::application::Application>>>, + ) { + self.inner.set_application(application); + } + + fn get_application( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::application::Application>>> { + self.inner.get_application() + } + + fn set_helper_set( + &mut self, + helper_set: Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>, + ) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>> { + self.inner.get_helper_set() + } + + fn merge_application_definition(&mut self, merge_args: bool) { + self.inner.merge_application_definition(merge_args); + } + + fn get_definition(&self) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_definition() + } + + fn get_native_definition( + &self, + ) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_native_definition() + } + + fn set_name(&mut self, name: &str) -> anyhow::Result<()> { + self.inner.set_name(name)?; + Ok(()) + } + + fn get_name(&self) -> Option<String> { + self.inner.get_name() + } + + fn set_hidden(&mut self, hidden: bool) { + self.inner.set_hidden(hidden); + } + + fn is_hidden(&self) -> bool { + self.inner.is_hidden() + } + + fn set_description(&mut self, description: &str) { + self.inner.set_description(description); + } + + fn get_description(&self) -> String { + self.inner.get_description() + } + + fn set_help(&mut self, help: &str) { + self.inner.set_help(help); + } + + fn get_help(&self) -> String { + self.inner.get_help() + } + + fn get_processed_help(&self) -> String { + self.inner.get_processed_help() + } + + fn set_aliases(&mut self, aliases: Vec<String>) -> anyhow::Result<()> { + self.inner.set_aliases(aliases)?; + Ok(()) + } + + fn get_aliases(&self) -> Vec<String> { + self.inner.get_aliases() + } + + fn get_synopsis(&mut self, short: bool) -> String { + self.inner.get_synopsis(short) + } + + fn get_usages(&self) -> Vec<String> { + self.inner.get_usages() + } + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result< + Result<PhpMixed, crate::symfony::console::exception::logic_exception::LogicException>, + > { + self.inner.get_helper(name) + } + + fn ignore_validation_errors(&mut self) { + self.inner.ignore_validation_errors(); + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command/lazy_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/lazy_command.rs new file mode 100644 index 0000000..47f73bf --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command/lazy_command.rs @@ -0,0 +1,395 @@ +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +use crate::symfony::console::application::Application; +use crate::symfony::console::command::command::{BaseCommand, Command, SetDefinitionArg}; +use crate::symfony::console::completion::completion_input::CompletionInput; +use crate::symfony::console::completion::completion_suggestions::CompletionSuggestions; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::output_interface::OutputInterface; + +/// Either an already-built command, or a factory closure that builds one. +/// +/// PHP: `private $command` holds a `Command` instance or a `\Closure`. +pub enum LazyCommandInner { + Command(Box<dyn Command>), + Factory(Box<dyn Fn() -> Box<dyn Command>>), +} + +impl std::fmt::Debug for LazyCommandInner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LazyCommandInner::Command(command) => f.debug_tuple("Command").field(command).finish(), + LazyCommandInner::Factory(_) => f.debug_tuple("Factory").finish(), + } + } +} + +#[derive(Debug)] +pub struct LazyCommand { + inner: BaseCommand, + command: LazyCommandInner, + is_enabled: Option<bool>, +} + +impl Deref for LazyCommand { + type Target = BaseCommand; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for LazyCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl LazyCommand { + pub fn new( + name: &str, + aliases: Vec<String>, + description: &str, + is_hidden: bool, + command_factory: Box<dyn Fn() -> Box<dyn Command>>, + is_enabled: Option<bool>, + ) -> anyhow::Result<Self> { + let mut this = Self { + inner: BaseCommand::__construct(None)?, + command: LazyCommandInner::Factory(command_factory), + is_enabled, + }; + + this.inner + .set_name(name)? + .set_aliases(aliases)? + .set_hidden(is_hidden) + .set_description(description); + + Ok(this) + } + + pub fn ignore_validation_errors(&mut self) { + self.get_command().ignore_validation_errors(); + } + + pub fn set_application(&mut self, application: Option<Rc<RefCell<Application>>>) { + // if ($this->command instanceof parent) + if let LazyCommandInner::Command(command) = &mut self.command { + command.set_application(application.clone()); + } + + // parent::setApplication($application); + self.inner.set_application(application); + } + + pub fn set_helper_set(&mut self, helper_set: Rc<RefCell<HelperSet>>) { + // if ($this->command instanceof parent) + if let LazyCommandInner::Command(command) = &mut self.command { + command.set_helper_set(helper_set.clone()); + } + + // parent::setHelperSet($helperSet); + self.inner.set_helper_set(helper_set); + } + + pub fn is_enabled(&mut self) -> bool { + // $this->isEnabled ?? $this->getCommand()->isEnabled() + match self.is_enabled { + Some(is_enabled) => is_enabled, + None => self.get_command().is_enabled(), + } + } + + pub fn run( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + self.get_command().run(input, output) + } + + pub fn complete(&mut self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + self.get_command().complete(input, suggestions); + } + + pub fn set_code( + &mut self, + code: Box<dyn Fn(&mut dyn InputInterface, &mut dyn OutputInterface) -> PhpMixed>, + ) -> &mut Self { + // TODO: Command::set_code() lives on BaseCommand's inherent API and is not part of + // the polymorphic `Command` trait, so it cannot be forwarded through `get_command()` + // (a `&mut Box<dyn Command>`). Resolving this needs `set_code` on the trait (Phase C). + let _ = code; + todo!() + } + + /// @internal + pub fn merge_application_definition(&mut self, merge_args: bool) { + self.get_command().merge_application_definition(merge_args); + } + + pub fn set_definition(&mut self, definition: SetDefinitionArg) -> &mut Self { + // TODO: Command::set_definition() is not part of the polymorphic `Command` trait; + // it cannot be forwarded through `get_command()` (Phase C). + let _ = definition; + todo!() + } + + pub fn get_definition(&mut self) -> &InputDefinition { + self.get_command().get_definition() + } + + pub fn get_native_definition(&mut self) -> &InputDefinition { + self.get_command().get_native_definition() + } + + pub fn add_argument( + &mut self, + name: &str, + mode: Option<i64>, + description: &str, + default: PhpMixed, + ) -> &mut Self { + // TODO: Command::add_argument() is not part of the polymorphic `Command` trait; + // it cannot be forwarded through `get_command()` (Phase C). + let _ = (name, mode, description, default); + todo!() + } + + pub fn add_option( + &mut self, + name: &str, + shortcut: PhpMixed, + mode: Option<i64>, + description: &str, + default: PhpMixed, + ) -> &mut Self { + // TODO: Command::add_option() is not part of the polymorphic `Command` trait; + // it cannot be forwarded through `get_command()` (Phase C). + let _ = (name, shortcut, mode, description, default); + todo!() + } + + pub fn set_process_title(&mut self, title: &str) -> &mut Self { + // TODO: Command::set_process_title() is not part of the polymorphic `Command` trait; + // it cannot be forwarded through `get_command()` (Phase C). + let _ = title; + todo!() + } + + pub fn set_help(&mut self, help: &str) -> &mut Self { + self.get_command().set_help(help); + + self + } + + pub fn get_help(&mut self) -> String { + self.get_command().get_help() + } + + pub fn get_processed_help(&mut self) -> String { + self.get_command().get_processed_help() + } + + pub fn get_synopsis(&mut self, short: bool) -> String { + self.get_command().get_synopsis(short) + } + + pub fn add_usage(&mut self, usage: &str) -> &mut Self { + // TODO: Command::add_usage() is not part of the polymorphic `Command` trait; + // it cannot be forwarded through `get_command()` (Phase C). + let _ = usage; + todo!() + } + + pub fn get_usages(&mut self) -> Vec<String> { + self.get_command().get_usages() + } + + pub fn get_helper( + &mut self, + name: &str, + ) -> anyhow::Result< + Result<PhpMixed, crate::symfony::console::exception::logic_exception::LogicException>, + > { + self.get_command().get_helper(name) + } + + pub fn get_command(&mut self) -> &mut Box<dyn Command> { + // if (!$this->command instanceof \Closure) { return $this->command; } + if let LazyCommandInner::Command(_) = &self.command { + if let LazyCommandInner::Command(command) = &mut self.command { + return command; + } + unreachable!() + } + + // $command = $this->command = ($this->command)(); + let mut command = match &self.command { + LazyCommandInner::Factory(factory) => factory(), + LazyCommandInner::Command(_) => unreachable!(), + }; + command.set_application(self.inner.get_application()); + + // if (null !== $this->getHelperSet()) + if let Some(helper_set) = self.inner.get_helper_set() { + command.set_helper_set(helper_set); + } + + let name = self.inner.get_name().unwrap_or_default(); + let aliases = self.inner.get_aliases(); + let hidden = self.inner.is_hidden(); + let description = self.inner.get_description(); + let _ = command.set_name(&name); + let _ = command.set_aliases(aliases); + command.set_hidden(hidden); + command.set_description(&description); + + // Will throw if the command is not correctly initialized. + command.get_definition(); + + self.command = LazyCommandInner::Command(command); + match &mut self.command { + LazyCommandInner::Command(command) => command, + LazyCommandInner::Factory(_) => unreachable!(), + } + } +} + +impl Command for LazyCommand { + fn configure(&mut self) { + // LazyCommand has no configure() of its own; nothing to do. + } + + fn run( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + LazyCommand::run(self, input, output) + } + + fn complete(&self, _input: &CompletionInput, _suggestions: &mut CompletionSuggestions) { + // TODO: LazyCommand::complete() lazily materializes the wrapped command and so needs + // `&mut self`, which conflicts with the `Command::complete(&self, ...)` signature + // (Phase C). + todo!() + } + + fn is_enabled(&self) -> bool { + // TODO: LazyCommand::is_enabled() lazily materializes the wrapped command and so needs + // `&mut self`, which conflicts with the trait signature (Phase C). + todo!() + } + + fn set_application(&mut self, application: Option<Rc<RefCell<Application>>>) { + LazyCommand::set_application(self, application); + } + + fn get_application(&self) -> Option<Rc<RefCell<Application>>> { + self.inner.get_application() + } + + fn set_helper_set(&mut self, helper_set: Rc<RefCell<HelperSet>>) { + LazyCommand::set_helper_set(self, helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn merge_application_definition(&mut self, merge_args: bool) { + LazyCommand::merge_application_definition(self, merge_args); + } + + fn get_definition(&self) -> &InputDefinition { + // TODO: LazyCommand::get_definition() lazily materializes the wrapped command and so + // needs `&mut self`, which conflicts with the trait signature (Phase C). + todo!() + } + + fn get_native_definition(&self) -> &InputDefinition { + // TODO: same lazy-materialization / `&self` conflict as get_definition() (Phase C). + todo!() + } + + fn set_name(&mut self, name: &str) -> anyhow::Result<()> { + self.inner.set_name(name)?; + Ok(()) + } + + fn get_name(&self) -> Option<String> { + self.inner.get_name() + } + + fn set_hidden(&mut self, hidden: bool) { + self.inner.set_hidden(hidden); + } + + fn is_hidden(&self) -> bool { + self.inner.is_hidden() + } + + fn set_description(&mut self, description: &str) { + self.inner.set_description(description); + } + + fn get_description(&self) -> String { + self.inner.get_description() + } + + fn set_help(&mut self, help: &str) { + LazyCommand::set_help(self, help); + } + + fn get_help(&self) -> String { + // TODO: LazyCommand::get_help() lazily materializes the wrapped command and so needs + // `&mut self`, which conflicts with the trait signature (Phase C). + todo!() + } + + fn get_processed_help(&self) -> String { + // TODO: same lazy-materialization / `&self` conflict (Phase C). + todo!() + } + + fn set_aliases(&mut self, aliases: Vec<String>) -> anyhow::Result<()> { + self.inner.set_aliases(aliases)?; + Ok(()) + } + + fn get_aliases(&self) -> Vec<String> { + self.inner.get_aliases() + } + + fn get_synopsis(&mut self, short: bool) -> String { + LazyCommand::get_synopsis(self, short) + } + + fn get_usages(&self) -> Vec<String> { + // TODO: LazyCommand::get_usages() lazily materializes the wrapped command and so needs + // `&mut self`, which conflicts with the trait signature (Phase C). + todo!() + } + + fn get_helper( + &self, + _name: &str, + ) -> anyhow::Result< + Result<PhpMixed, crate::symfony::console::exception::logic_exception::LogicException>, + > { + // TODO: LazyCommand::get_helper() lazily materializes the wrapped command and so needs + // `&mut self`, which conflicts with the trait signature (Phase C). + todo!() + } + + fn ignore_validation_errors(&mut self) { + LazyCommand::ignore_validation_errors(self); + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command/list_command.rs b/crates/shirabe-external-packages/src/symfony/console/command/list_command.rs new file mode 100644 index 0000000..7581c90 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command/list_command.rs @@ -0,0 +1,269 @@ +use crate::symfony::console::command::command::{BaseCommand, Command, SetDefinitionArg}; +use crate::symfony::console::completion::completion_input::CompletionInput; +use crate::symfony::console::completion::completion_suggestions::{ + CompletionSuggestions, StringOrSuggestion, +}; +use crate::symfony::console::descriptor::application_description::ApplicationDescription; +use crate::symfony::console::helper::descriptor_helper::DescriptorHelper; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_definition::DefinitionItem; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::input_option::InputOption; +use crate::symfony::console::output::output_interface::OutputInterface; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +/// ListCommand displays the list of all available commands for the application. +#[derive(Debug)] +pub struct ListCommand { + inner: BaseCommand, +} + +impl Deref for ListCommand { + type Target = BaseCommand; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for ListCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl ListCommand { + fn configure(&mut self) -> anyhow::Result<()> { + self.inner + .set_name("list")? + .set_definition(SetDefinitionArg::Array(vec![ + DefinitionItem::InputArgument(InputArgument::new( + "namespace".to_string(), + Some(InputArgument::OPTIONAL), + "The namespace name".to_string(), + PhpMixed::Null, + )?), + DefinitionItem::InputOption(InputOption::new( + "raw", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To output raw command list".to_string(), + PhpMixed::Null, + )?), + DefinitionItem::InputOption(InputOption::new( + "format", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED), + "The output format (txt, xml, json, or md)".to_string(), + PhpMixed::from("txt".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "short", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To skip describing commands' arguments".to_string(), + PhpMixed::Null, + )?), + ])) + .set_description("List commands") + .set_help( + "The <info>%command.name%</info> command lists all commands:\n\ + \n\ + \x20\x20<info>%command.full_name%</info>\n\ + \n\ + You can also display the commands for a specific namespace:\n\ + \n\ + \x20\x20<info>%command.full_name% test</info>\n\ + \n\ + You can also output the information in other formats by using the <comment>--format</comment> option:\n\ + \n\ + \x20\x20<info>%command.full_name% --format=xml</info>\n\ + \n\ + It's also possible to get raw list of commands (useful for embedding command runner):\n\ + \n\ + \x20\x20<info>%command.full_name% --raw</info>", + ); + + Ok(()) + } + + fn execute( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + let helper = DescriptorHelper::new(); + // TODO: DescriptorHelper::describe2 takes the described object as Option<PhpMixed>, + // but PhpMixed cannot hold an Application. The Command/Application object mixing for + // describe needs a dedicated type (Phase C). + let object: Option<PhpMixed> = todo!(); + let mut options = indexmap::IndexMap::new(); + options.insert("format".to_string(), input.get_option("format")?); + options.insert("raw_text".to_string(), input.get_option("raw")?); + options.insert("namespace".to_string(), input.get_argument("namespace")?); + options.insert("short".to_string(), input.get_option("short")?); + let _ = helper.describe2(output, object, options); + + Ok(0) + } + + pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + if input.must_suggest_argument_values_for("namespace") { + let application = self.get_application().unwrap(); + let mut descriptor = ApplicationDescription::new(application, None, false); + suggestions.suggest_values( + descriptor + .get_namespaces() + .keys() + .cloned() + .map(StringOrSuggestion::String) + .collect(), + ); + + return; + } + + if input.must_suggest_option_values_for("format") { + let helper = DescriptorHelper::new(); + suggestions.suggest_values( + helper + .get_formats() + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + } +} + +impl Command for ListCommand { + fn configure(&mut self) { + let _ = ListCommand::configure(self); + } + + fn run( + &mut self, + input: &mut dyn InputInterface, + output: &mut dyn OutputInterface, + ) -> anyhow::Result<i64> { + self.inner.run(input, output) + } + + fn complete(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + self.complete_impl(input, suggestions); + } + + fn is_enabled(&self) -> bool { + self.inner.is_enabled() + } + + fn set_application( + &mut self, + application: Option<Rc<RefCell<crate::symfony::console::application::Application>>>, + ) { + self.inner.set_application(application); + } + + fn get_application( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::application::Application>>> { + self.inner.get_application() + } + + fn set_helper_set( + &mut self, + helper_set: Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>, + ) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set( + &self, + ) -> Option<Rc<RefCell<crate::symfony::console::helper::helper_set::HelperSet>>> { + self.inner.get_helper_set() + } + + fn merge_application_definition(&mut self, merge_args: bool) { + self.inner.merge_application_definition(merge_args); + } + + fn get_definition(&self) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_definition() + } + + fn get_native_definition( + &self, + ) -> &crate::symfony::console::input::input_definition::InputDefinition { + self.inner.get_native_definition() + } + + fn set_name(&mut self, name: &str) -> anyhow::Result<()> { + self.inner.set_name(name)?; + Ok(()) + } + + fn get_name(&self) -> Option<String> { + self.inner.get_name() + } + + fn set_hidden(&mut self, hidden: bool) { + self.inner.set_hidden(hidden); + } + + fn is_hidden(&self) -> bool { + self.inner.is_hidden() + } + + fn set_description(&mut self, description: &str) { + self.inner.set_description(description); + } + + fn get_description(&self) -> String { + self.inner.get_description() + } + + fn set_help(&mut self, help: &str) { + self.inner.set_help(help); + } + + fn get_help(&self) -> String { + self.inner.get_help() + } + + fn get_processed_help(&self) -> String { + self.inner.get_processed_help() + } + + fn set_aliases(&mut self, aliases: Vec<String>) -> anyhow::Result<()> { + self.inner.set_aliases(aliases)?; + Ok(()) + } + + fn get_aliases(&self) -> Vec<String> { + self.inner.get_aliases() + } + + fn get_synopsis(&mut self, short: bool) -> String { + self.inner.get_synopsis(short) + } + + fn get_usages(&self) -> Vec<String> { + self.inner.get_usages() + } + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result< + Result<PhpMixed, crate::symfony::console::exception::logic_exception::LogicException>, + > { + self.inner.get_helper(name) + } + + fn ignore_validation_errors(&mut self) { + self.inner.ignore_validation_errors(); + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command/mod.rs b/crates/shirabe-external-packages/src/symfony/console/command/mod.rs index 375170c..ee2dd4f 100644 --- a/crates/shirabe-external-packages/src/symfony/console/command/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/command/mod.rs @@ -1,3 +1,15 @@ pub mod command; +pub mod complete_command; +pub mod dump_completion_command; +pub mod help_command; +pub mod lazy_command; +pub mod list_command; +pub mod signalable_command_interface; pub use command::*; +pub use complete_command::*; +pub use dump_completion_command::*; +pub use help_command::*; +pub use lazy_command::*; +pub use list_command::*; +pub use signalable_command_interface::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/command/signalable_command_interface.rs b/crates/shirabe-external-packages/src/symfony/console/command/signalable_command_interface.rs new file mode 100644 index 0000000..1af4a96 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command/signalable_command_interface.rs @@ -0,0 +1,8 @@ +/// Interface for command reacting to signal. +pub trait SignalableCommandInterface { + /// Returns the list of signals to subscribe. + fn get_subscribed_signals(&self) -> Vec<i64>; + + /// The method will be called when the application is signaled. + fn handle_signal(&mut self, signal: i64); +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command_loader/command_loader_interface.rs b/crates/shirabe-external-packages/src/symfony/console/command_loader/command_loader_interface.rs new file mode 100644 index 0000000..f96adc9 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command_loader/command_loader_interface.rs @@ -0,0 +1,13 @@ +use crate::symfony::console::command::command::Command; + +pub trait CommandLoaderInterface: std::fmt::Debug { + /// Loads a command. + /// + /// @throws CommandNotFoundException + fn get(&self, name: &str) -> Box<dyn Command>; + + /// Checks if a command exists. + fn has(&self, name: &str) -> bool; + + fn get_names(&self) -> Vec<String>; +} diff --git a/crates/shirabe-external-packages/src/symfony/console/command_loader/mod.rs b/crates/shirabe-external-packages/src/symfony/console/command_loader/mod.rs new file mode 100644 index 0000000..97b3f54 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/command_loader/mod.rs @@ -0,0 +1,3 @@ +pub mod command_loader_interface; + +pub use command_loader_interface::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs b/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs index 1203ec8..32d83ce 100644 --- a/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs @@ -1,24 +1,296 @@ +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +use crate::symfony::console::input::argv_input::ArgvInput; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_option::InputOption; + +/// An input specialized for shell completion. +/// +/// This input allows unfinished option names or values and exposes what kind of +/// completion is expected. #[derive(Debug)] -pub struct CompletionInput; +pub struct CompletionInput { + inner: ArgvInput, + tokens: Vec<String>, + current_index: i64, + completion_type: String, + completion_name: Option<String>, + completion_value: String, +} impl CompletionInput { + pub const TYPE_ARGUMENT_VALUE: &'static str = "argument_value"; + pub const TYPE_OPTION_VALUE: &'static str = "option_value"; + pub const TYPE_OPTION_NAME: &'static str = "option_name"; + pub const TYPE_NONE: &'static str = "none"; + + /// Converts a terminal string into tokens. + /// + /// This is required for shell completions without COMP_WORDS support. + pub fn from_string(input_str: &str, current_index: i64) -> anyhow::Result<Self> { + let tokens = shirabe_php_shim::preg_match_all( + "/(?<=^|\\s)(['\"]?)(.+?)(?<!\\\\)\\1(?=$|\\s)/", + input_str, + ); + + Self::from_tokens(tokens[0].clone(), current_index) + } + + /// Create an input based on an COMP_WORDS token list. + /// + /// `tokens` is the set of split tokens (e.g. COMP_WORDS or argv), + /// `current_index` the index of the cursor (e.g. COMP_CWORD). + pub fn from_tokens(tokens: Vec<String>, current_index: i64) -> anyhow::Result<Self> { + let mut input = Self { + inner: ArgvInput::new(Some(tokens.clone()), None)?, + tokens: vec![], + current_index: 0, + completion_type: String::new(), + completion_name: None, + completion_value: String::new(), + }; + input.tokens = tokens; + input.current_index = current_index; + + Ok(input) + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.inner.bind(definition)?; + + let relevant_token = self.get_relevant_token(); + if "-" == &relevant_token[0..1] { + // the current token is an input option: complete either option name or option value + let parts = shirabe_php_shim::explode_with_limit("=", &relevant_token, 2); + let option_token = parts.first().cloned().unwrap_or_default(); + let option_value = parts.get(1).cloned().unwrap_or_default(); + + let option = self.get_option_from_token(&option_token); + if option.is_none() && !self.is_cursor_free() { + self.completion_type = Self::TYPE_OPTION_NAME.to_string(); + self.completion_value = relevant_token; + + return Ok(()); + } + + if let Some(option) = &option { + if option.accept_value() { + self.completion_type = Self::TYPE_OPTION_VALUE.to_string(); + self.completion_name = Some(option.get_name().to_string()); + self.completion_value = if !option_value.is_empty() { + option_value + } else if !shirabe_php_shim::str_starts_with(&option_token, "--") { + shirabe_php_shim::substr(&option_token, 2, None) + } else { + String::new() + }; + + return Ok(()); + } + } + } + + let previous_token = self.tokens[(self.current_index - 1) as usize].clone(); + if "-" == &previous_token[0..1] && "" != shirabe_php_shim::trim(&previous_token, Some("-")) + { + // check if previous option accepted a value + let previous_option = self.get_option_from_token(&previous_token); + if let Some(previous_option) = &previous_option { + if previous_option.accept_value() { + self.completion_type = Self::TYPE_OPTION_VALUE.to_string(); + self.completion_name = Some(previous_option.get_name().to_string()); + self.completion_value = relevant_token; + + return Ok(()); + } + } + } + + // complete argument value + self.completion_type = Self::TYPE_ARGUMENT_VALUE.to_string(); + + let mut argument_name: Option<String> = None; + let argument_names: Vec<String> = self + .inner + .inner + .definition + .get_arguments() + .keys() + .cloned() + .collect(); + for current_argument_name in argument_names { + if !self + .inner + .inner + .arguments + .contains_key(¤t_argument_name) + { + break; + } + argument_name = Some(current_argument_name.clone()); + + let argument_value = self.inner.inner.arguments[¤t_argument_name].clone(); + self.completion_name = Some(current_argument_name.clone()); + if let PhpMixed::Array(argument_value) = &argument_value { + self.completion_value = if !argument_value.is_empty() { + argument_value[shirabe_php_shim::array_key_last(argument_value)].to_string() + } else { + // null + String::new() + }; + } else { + self.completion_value = argument_value.to_string(); + } + } + + if self.current_index >= self.tokens.len() as i64 { + let argument_name = argument_name.unwrap_or_default(); + if !self.inner.inner.arguments.contains_key(&argument_name) + || self + .inner + .inner + .definition + .get_argument(&PhpMixed::String(argument_name.clone())) + .unwrap() + .is_array() + { + self.completion_name = Some(argument_name); + self.completion_value = String::new(); + } else { + // we've reached the end + self.completion_type = Self::TYPE_NONE.to_string(); + self.completion_name = None; + self.completion_value = String::new(); + } + } + + Ok(()) + } + + /// Returns the type of completion required. + /// + /// TYPE_ARGUMENT_VALUE when completing the value of an input argument + /// TYPE_OPTION_VALUE when completing the value of an input option + /// TYPE_OPTION_NAME when completing the name of an input option + /// TYPE_NONE when nothing should be completed pub fn get_completion_type(&self) -> String { - todo!() + self.completion_type.clone() } + /// The name of the input option or argument when completing a value. + /// + /// Returns null when completing an option name. pub fn get_completion_name(&self) -> Option<String> { - todo!() + self.completion_name.clone() } + /// The value already typed by the user (or empty string). pub fn get_completion_value(&self) -> String { - todo!() + self.completion_value.clone() + } + + pub fn must_suggest_option_values_for(&self, option_name: &str) -> bool { + Self::TYPE_OPTION_VALUE == self.get_completion_type() + && Some(option_name.to_string()) == self.get_completion_name() + } + + pub fn must_suggest_argument_values_for(&self, argument_name: &str) -> bool { + Self::TYPE_ARGUMENT_VALUE == self.get_completion_type() + && Some(argument_name.to_string()) == self.get_completion_name() + } + + pub fn get_first_argument(&self) -> Option<String> { + self.inner.get_first_argument() + } + + pub(crate) fn parse_token(&mut self, token: &str, parse_options: bool) -> bool { + match self.inner.parse_token(token, parse_options) { + Ok(value) => return value, + Err(_e) => { + // suppress errors, completed input is almost never valid + } + } + + parse_options + } + + fn get_option_from_token(&self, option_token: &str) -> Option<std::rc::Rc<InputOption>> { + let option_name = shirabe_php_shim::ltrim(option_token, Some("-")); + if option_name.is_empty() { + return None; + } + + if "-" + == option_token + .chars() + .nth(1) + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".to_string()) + { + // long option name + return if self.inner.inner.definition.has_option(&option_name) { + self.inner.inner.definition.get_option(&option_name).ok() + } else { + None + }; + } + + // short option name + let first = &option_name[0..1]; + if self.inner.inner.definition.has_shortcut(first) { + self.inner + .inner + .definition + .get_option_for_shortcut(first) + .ok() + } else { + None + } } - pub fn must_suggest_option_values_for(&self, _name: &str) -> bool { - todo!() + /// The token of the cursor, or the last token if the cursor is at the end of the input. + fn get_relevant_token(&self) -> String { + let index = if self.is_cursor_free() { + self.current_index - 1 + } else { + self.current_index + }; + self.tokens[index as usize].clone() + } + + /// Whether the cursor is "free" (i.e. at the end of the input preceded by a space). + fn is_cursor_free(&self) -> bool { + let nr_of_tokens = self.tokens.len() as i64; + if self.current_index > nr_of_tokens { + // LogicException: recoverable usage as a "convenient fatal error"; panic. + panic!("Current index is invalid, it must be the number of input tokens or one more."); + } + + self.current_index >= nr_of_tokens } +} + +impl std::fmt::Display for CompletionInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut str = String::new(); + let mut last_i: i64 = 0; + for (i, token) in self.tokens.iter().enumerate() { + let i = i as i64; + last_i = i; + str += token; + + if self.current_index == i { + str += "|"; + } + + str += " "; + } + + if self.current_index > last_i { + str += "|"; + } - pub fn must_suggest_argument_values_for(&self, _name: &str) -> bool { - todo!() + write!(f, "{}", shirabe_php_shim::rtrim(&str, None)) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/completion_suggestions.rs b/crates/shirabe-external-packages/src/symfony/console/completion/completion_suggestions.rs index 7dae1fc..fd1a317 100644 --- a/crates/shirabe-external-packages/src/symfony/console/completion/completion_suggestions.rs +++ b/crates/shirabe-external-packages/src/symfony/console/completion/completion_suggestions.rs @@ -1,14 +1,68 @@ -use shirabe_php_shim::PhpMixed; +use crate::symfony::console::completion::suggestion::Suggestion; +use crate::symfony::console::input::input_option::InputOption; +/// PHP union type `string|Suggestion` used by `suggestValue`/`suggestValues`. #[derive(Debug)] -pub struct CompletionSuggestions; +pub enum StringOrSuggestion { + String(String), + Suggestion(Suggestion), +} + +/// Stores all completion suggestions for the current input. +#[derive(Debug)] +pub struct CompletionSuggestions { + value_suggestions: Vec<Suggestion>, + option_suggestions: Vec<InputOption>, +} impl CompletionSuggestions { - pub fn suggest_values(&mut self, _values: Vec<PhpMixed>) { - todo!() + pub fn new() -> Self { + Self { + value_suggestions: vec![], + option_suggestions: vec![], + } + } + + /// Add a suggested value for an input option or argument. + pub fn suggest_value(&mut self, value: StringOrSuggestion) -> &mut Self { + self.value_suggestions.push(match value { + StringOrSuggestion::Suggestion(value) => value, + StringOrSuggestion::String(value) => Suggestion::new(value), + }); + + self + } + + /// Add multiple suggested values at once for an input option or argument. + pub fn suggest_values(&mut self, values: Vec<StringOrSuggestion>) -> &mut Self { + for value in values { + self.suggest_value(value); + } + + self + } + + /// Add a suggestion for an input option name. + pub fn suggest_option(&mut self, option: InputOption) -> &mut Self { + self.option_suggestions.push(option); + + self + } + + /// Add multiple suggestions for input option names at once. + pub fn suggest_options(&mut self, options: Vec<InputOption>) -> &mut Self { + for option in options { + self.suggest_option(option); + } + + self + } + + pub fn get_option_suggestions(&self) -> &Vec<InputOption> { + &self.option_suggestions } - pub fn suggest_value(&mut self, _value: PhpMixed) { - todo!() + pub fn get_value_suggestions(&self) -> &Vec<Suggestion> { + &self.value_suggestions } } diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/mod.rs b/crates/shirabe-external-packages/src/symfony/console/completion/mod.rs index 0d1c115..cb7f1d1 100644 --- a/crates/shirabe-external-packages/src/symfony/console/completion/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/completion/mod.rs @@ -1,7 +1,9 @@ pub mod completion_input; pub mod completion_suggestions; +pub mod output; pub mod suggestion; pub use completion_input::*; pub use completion_suggestions::*; +pub use output::*; pub use suggestion::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/output/bash_completion_output.rs b/crates/shirabe-external-packages/src/symfony/console/completion/output/bash_completion_output.rs new file mode 100644 index 0000000..b781f51 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/completion/output/bash_completion_output.rs @@ -0,0 +1,23 @@ +use crate::symfony::console::completion::completion_suggestions::CompletionSuggestions; +use crate::symfony::console::completion::output::completion_output_interface::CompletionOutputInterface; +use crate::symfony::console::output::output_interface::OutputInterface; + +#[derive(Debug)] +pub struct BashCompletionOutput; + +impl CompletionOutputInterface for BashCompletionOutput { + fn write(&self, suggestions: &CompletionSuggestions, output: &dyn OutputInterface) { + let mut values: Vec<String> = suggestions + .get_value_suggestions() + .iter() + .map(|suggestion| suggestion.get_value()) + .collect(); + for option in suggestions.get_option_suggestions() { + values.push(format!("--{}", option.get_name())); + if option.is_negatable() { + values.push(format!("--no-{}", option.get_name())); + } + } + output.writeln(&[values.join("\n")], 0); + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/output/completion_output_interface.rs b/crates/shirabe-external-packages/src/symfony/console/completion/output/completion_output_interface.rs new file mode 100644 index 0000000..e92adf3 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/completion/output/completion_output_interface.rs @@ -0,0 +1,7 @@ +use crate::symfony::console::completion::completion_suggestions::CompletionSuggestions; +use crate::symfony::console::output::output_interface::OutputInterface; + +/// Transforms the `CompletionSuggestions` object into output readable by the shell completion. +pub trait CompletionOutputInterface: std::fmt::Debug { + fn write(&self, suggestions: &CompletionSuggestions, output: &dyn OutputInterface); +} diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/output/mod.rs b/crates/shirabe-external-packages/src/symfony/console/completion/output/mod.rs new file mode 100644 index 0000000..6bb0a2e --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/completion/output/mod.rs @@ -0,0 +1,5 @@ +pub mod bash_completion_output; +pub mod completion_output_interface; + +pub use bash_completion_output::*; +pub use completion_output_interface::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/suggestion.rs b/crates/shirabe-external-packages/src/symfony/console/completion/suggestion.rs index 666e474..5c45b92 100644 --- a/crates/shirabe-external-packages/src/symfony/console/completion/suggestion.rs +++ b/crates/shirabe-external-packages/src/symfony/console/completion/suggestion.rs @@ -1,16 +1,21 @@ +/// Represents a single suggested value. #[derive(Debug)] -pub struct Suggestion; +pub struct Suggestion { + value: String, +} impl Suggestion { - pub fn new(_value: String, _description: Option<String>) -> Self { - todo!() + pub fn new(value: String) -> Self { + Self { value } } pub fn get_value(&self) -> String { - todo!() + self.value.clone() } +} - pub fn get_description(&self) -> String { - todo!() +impl std::fmt::Display for Suggestion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.get_value()) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/console_events.rs b/crates/shirabe-external-packages/src/symfony/console/console_events.rs new file mode 100644 index 0000000..796f8b4 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/console_events.rs @@ -0,0 +1,44 @@ +/// Contains all events dispatched by an Application. +#[derive(Debug)] +pub struct ConsoleEvents; + +impl ConsoleEvents { + /// The COMMAND event allows you to attach listeners before any command is + /// executed by the console. It also allows you to modify the command, input and output + /// before they are handed to the command. + pub const COMMAND: &'static str = "console.command"; + + /// The SIGNAL event allows you to perform some actions + /// after the command execution was interrupted. + pub const SIGNAL: &'static str = "console.signal"; + + /// The TERMINATE event allows you to attach listeners after a command is + /// executed by the console. + pub const TERMINATE: &'static str = "console.terminate"; + + /// The ERROR event occurs when an uncaught exception or error appears. + /// + /// This event allows you to deal with the exception/error or + /// to modify the thrown exception. + pub const ERROR: &'static str = "console.error"; + + /// Event aliases. These aliases can be consumed by RegisterListenersPass. + pub const ALIASES: &'static [(&'static str, &'static str)] = &[ + ( + "Symfony\\Component\\Console\\Event\\ConsoleCommandEvent", + Self::COMMAND, + ), + ( + "Symfony\\Component\\Console\\Event\\ConsoleErrorEvent", + Self::ERROR, + ), + ( + "Symfony\\Component\\Console\\Event\\ConsoleSignalEvent", + Self::SIGNAL, + ), + ( + "Symfony\\Component\\Console\\Event\\ConsoleTerminateEvent", + Self::TERMINATE, + ), + ]; +} diff --git a/crates/shirabe-external-packages/src/symfony/console/cursor.rs b/crates/shirabe-external-packages/src/symfony/console/cursor.rs new file mode 100644 index 0000000..9016fab --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/cursor.rs @@ -0,0 +1,259 @@ +use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::output_interface; +use std::cell::RefCell; +use std::rc::Rc; + +#[derive(Debug)] +pub struct Cursor { + output: Rc<RefCell<dyn OutputInterface>>, + input: shirabe_php_shim::PhpMixed, +} + +impl Cursor { + pub fn new( + output: Rc<RefCell<dyn OutputInterface>>, + input: Option<shirabe_php_shim::PhpMixed>, + ) -> Self { + let input = input.unwrap_or_else(|| { + if shirabe_php_shim::defined("STDIN") { + // PHP uses the `STDIN` resource constant here, but the shim models it as + // `PhpResource` while this field is `PhpMixed` (which has no resource + // variant). Fall back to opening `php://input` so the value stays a + // PhpMixed stream handle. + shirabe_php_shim::fopen("php://input", "r+") + } else { + shirabe_php_shim::fopen("php://input", "r+") + } + }); + + Self { output, input } + } + + pub fn move_up(&self, lines: i64) -> &Self { + self.output.borrow().write( + &[shirabe_php_shim::sprintf( + "\x1b[%dA", + &[shirabe_php_shim::PhpMixed::Int(lines)], + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn move_down(&self, lines: i64) -> &Self { + self.output.borrow().write( + &[shirabe_php_shim::sprintf( + "\x1b[%dB", + &[shirabe_php_shim::PhpMixed::Int(lines)], + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn move_right(&self, columns: i64) -> &Self { + self.output.borrow().write( + &[shirabe_php_shim::sprintf( + "\x1b[%dC", + &[shirabe_php_shim::PhpMixed::Int(columns)], + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn move_left(&self, columns: i64) -> &Self { + self.output.borrow().write( + &[shirabe_php_shim::sprintf( + "\x1b[%dD", + &[shirabe_php_shim::PhpMixed::Int(columns)], + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn move_to_column(&self, column: i64) -> &Self { + self.output.borrow().write( + &[shirabe_php_shim::sprintf( + "\x1b[%dG", + &[shirabe_php_shim::PhpMixed::Int(column)], + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn move_to_position(&self, column: i64, row: i64) -> &Self { + self.output.borrow().write( + &[shirabe_php_shim::sprintf( + "\x1b[%d;%dH", + &[ + shirabe_php_shim::PhpMixed::Int(row + 1), + shirabe_php_shim::PhpMixed::Int(column), + ], + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn save_position(&self) -> &Self { + self.output.borrow().write( + &["\x1b7".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn restore_position(&self) -> &Self { + self.output.borrow().write( + &["\x1b8".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn hide(&self) -> &Self { + self.output.borrow().write( + &["\x1b[?25l".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn show(&self) -> &Self { + self.output.borrow().write( + &["\x1b[?25h\x1b[?0c".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + /// Clears all the output from the current line. + pub fn clear_line(&self) -> &Self { + self.output.borrow().write( + &["\x1b[2K".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + /// Clears all the output from the current line after the current position. + pub fn clear_line_after(&self) -> &Self { + self.output.borrow().write( + &["\x1b[K".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + /// Clears all the output from the cursors' current position to the end of the screen. + pub fn clear_output(&self) -> &Self { + self.output.borrow().write( + &["\x1b[0J".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + /// Clears the entire screen. + pub fn clear_screen(&self) -> &Self { + self.output.borrow().write( + &["\x1b[2J".to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + /// Returns the current cursor position as x,y coordinates. + pub fn get_current_position(&self) -> Vec<i64> { + static IS_TTY_SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); + + let is_tty_supported = if shirabe_php_shim::function_exists("proc_open") { + *IS_TTY_SUPPORTED.get_or_init(|| { + let mut pipes = shirabe_php_shim::PhpMixed::Null; + shirabe_php_shim::proc_open( + "echo 1 >/dev/null", + &vec![ + shirabe_php_shim::PhpMixed::List(vec![ + Box::new(shirabe_php_shim::PhpMixed::String("file".to_string())), + Box::new(shirabe_php_shim::PhpMixed::String("/dev/tty".to_string())), + Box::new(shirabe_php_shim::PhpMixed::String("r".to_string())), + ]), + shirabe_php_shim::PhpMixed::List(vec![ + Box::new(shirabe_php_shim::PhpMixed::String("file".to_string())), + Box::new(shirabe_php_shim::PhpMixed::String("/dev/tty".to_string())), + Box::new(shirabe_php_shim::PhpMixed::String("w".to_string())), + ]), + shirabe_php_shim::PhpMixed::List(vec![ + Box::new(shirabe_php_shim::PhpMixed::String("file".to_string())), + Box::new(shirabe_php_shim::PhpMixed::String("/dev/tty".to_string())), + Box::new(shirabe_php_shim::PhpMixed::String("w".to_string())), + ]), + ], + &mut pipes, + ) + }) + } else { + false + }; + + if !is_tty_supported { + return vec![1, 1]; + } + + let stty_mode = shirabe_php_shim::shell_exec("stty -g"); + shirabe_php_shim::shell_exec("stty -icanon -echo"); + + shirabe_php_shim::fwrite(self.input.clone(), "\x1b[6n", 0); + + let code = shirabe_php_shim::trim( + shirabe_php_shim::fread(self.input.clone(), 1024) + .as_deref() + .unwrap_or(""), + None, + ); + + shirabe_php_shim::shell_exec(&shirabe_php_shim::sprintf( + "stty %s", + &[shirabe_php_shim::PhpMixed::String( + stty_mode.unwrap_or_default(), + )], + )); + + let mut row: i64 = 0; + let mut col: i64 = 0; + shirabe_php_shim::sscanf(&code, "\x1b[%d;%dR", &mut row, &mut col); + + vec![col, row] + } +} 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) + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/event/console_command_event.rs b/crates/shirabe-external-packages/src/symfony/console/event/console_command_event.rs new file mode 100644 index 0000000..5401b77 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/event/console_command_event.rs @@ -0,0 +1,43 @@ +use super::console_event::ConsoleEvent; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::output_interface::OutputInterface; + +/// Allows to do things before the command is executed, like skipping the command or +/// executing code before the command is going to be executed. +/// +/// Changing the input arguments will have no effect. +#[derive(Debug)] +pub struct ConsoleCommandEvent { + inner: ConsoleEvent, + command_should_run: bool, +} + +impl ConsoleCommandEvent { + pub const RETURN_CODE_DISABLED: i64 = 113; + + pub fn new( + command: Option<Box<dyn Command>>, + input: Box<dyn InputInterface>, + output: Box<dyn OutputInterface>, + ) -> Self { + Self { + inner: ConsoleEvent::new(command, input, output), + command_should_run: true, + } + } + + pub fn disable_command(&mut self) -> bool { + self.command_should_run = false; + self.command_should_run + } + + pub fn enable_command(&mut self) -> bool { + self.command_should_run = true; + self.command_should_run + } + + pub fn command_should_run(&self) -> bool { + self.command_should_run + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/event/console_error_event.rs b/crates/shirabe-external-packages/src/symfony/console/event/console_error_event.rs new file mode 100644 index 0000000..0fec8ec --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/event/console_error_event.rs @@ -0,0 +1,52 @@ +use super::console_event::ConsoleEvent; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::output_interface::OutputInterface; + +#[derive(Debug)] +pub struct ConsoleErrorEvent { + inner: ConsoleEvent, + error: Box<dyn std::error::Error + Send + Sync>, + exit_code: Option<i64>, +} + +impl ConsoleErrorEvent { + pub fn new( + input: Box<dyn InputInterface>, + output: Box<dyn OutputInterface>, + error: Box<dyn std::error::Error + Send + Sync>, + command: Option<Box<dyn Command>>, + ) -> Self { + Self { + inner: ConsoleEvent::new(command, input, output), + error, + exit_code: None, + } + } + + pub fn get_error(&self) -> &(dyn std::error::Error + Send + Sync) { + self.error.as_ref() + } + + pub fn set_error(&mut self, error: Box<dyn std::error::Error + Send + Sync>) { + self.error = error; + } + + pub fn set_exit_code(&mut self, exit_code: i64) { + self.exit_code = Some(exit_code); + // TODO: The PHP implementation uses \ReflectionProperty to forcibly set the `code` + // field on the error object. This requires a Reflection API equivalent; review needed. + todo!("set error code via reflection equivalent") + } + + pub fn get_exit_code(&self) -> i64 { + match self.exit_code { + Some(code) => code, + None => { + // PHP: is_int($error->getCode()) && 0 !== $error->getCode() ? $error->getCode() : 1 + // Throwable::getCode() has no direct equivalent on std::error::Error. + todo!("retrieve error code from Throwable equivalent") + } + } + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/event/console_event.rs b/crates/shirabe-external-packages/src/symfony/console/event/console_event.rs new file mode 100644 index 0000000..96b9a31 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/event/console_event.rs @@ -0,0 +1,40 @@ +use crate::symfony::console::command::command::Command; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::output_interface::OutputInterface; +use crate::symfony::contracts::event_dispatcher::event::Event; + +/// Allows to inspect input and output of a command. +#[derive(Debug)] +pub struct ConsoleEvent { + inner: Event, + pub(crate) command: Option<Box<dyn Command>>, + input: Box<dyn InputInterface>, + output: Box<dyn OutputInterface>, +} + +impl ConsoleEvent { + pub fn new( + command: Option<Box<dyn Command>>, + input: Box<dyn InputInterface>, + output: Box<dyn OutputInterface>, + ) -> Self { + Self { + inner: Event, + command, + input, + output, + } + } + + pub fn get_command(&self) -> Option<&dyn Command> { + self.command.as_deref() + } + + pub fn get_input(&self) -> &dyn InputInterface { + self.input.as_ref() + } + + pub fn get_output(&self) -> &dyn OutputInterface { + self.output.as_ref() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/event/console_signal_event.rs b/crates/shirabe-external-packages/src/symfony/console/event/console_signal_event.rs new file mode 100644 index 0000000..424285d --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/event/console_signal_event.rs @@ -0,0 +1,28 @@ +use super::console_event::ConsoleEvent; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::output_interface::OutputInterface; + +#[derive(Debug)] +pub struct ConsoleSignalEvent { + inner: ConsoleEvent, + handling_signal: i64, +} + +impl ConsoleSignalEvent { + pub fn new( + command: Box<dyn Command>, + input: Box<dyn InputInterface>, + output: Box<dyn OutputInterface>, + handling_signal: i64, + ) -> Self { + Self { + inner: ConsoleEvent::new(Some(command), input, output), + handling_signal, + } + } + + pub fn get_handling_signal(&self) -> i64 { + self.handling_signal + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/event/console_terminate_event.rs b/crates/shirabe-external-packages/src/symfony/console/event/console_terminate_event.rs new file mode 100644 index 0000000..c9c06fa --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/event/console_terminate_event.rs @@ -0,0 +1,35 @@ +use super::console_event::ConsoleEvent; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::output_interface::OutputInterface; + +/// Allows to manipulate the exit code of a command after its execution. +#[derive(Debug)] +pub struct ConsoleTerminateEvent { + inner: ConsoleEvent, + exit_code: i64, +} + +impl ConsoleTerminateEvent { + pub fn new( + command: Box<dyn Command>, + input: Box<dyn InputInterface>, + output: Box<dyn OutputInterface>, + exit_code: i64, + ) -> Self { + let mut instance = Self { + inner: ConsoleEvent::new(Some(command), input, output), + exit_code: 0, + }; + instance.set_exit_code(exit_code); + instance + } + + pub fn set_exit_code(&mut self, exit_code: i64) { + self.exit_code = exit_code; + } + + pub fn get_exit_code(&self) -> i64 { + self.exit_code + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/event/mod.rs b/crates/shirabe-external-packages/src/symfony/console/event/mod.rs new file mode 100644 index 0000000..efc6b2d --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/event/mod.rs @@ -0,0 +1,11 @@ +pub mod console_command_event; +pub mod console_error_event; +pub mod console_event; +pub mod console_signal_event; +pub mod console_terminate_event; + +pub use console_command_event::*; +pub use console_error_event::*; +pub use console_event::*; +pub use console_signal_event::*; +pub use console_terminate_event::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/command_not_found_exception.rs b/crates/shirabe-external-packages/src/symfony/console/exception/command_not_found_exception.rs index f1ed60b..ea259b3 100644 --- a/crates/shirabe-external-packages/src/symfony/console/exception/command_not_found_exception.rs +++ b/crates/shirabe-external-packages/src/symfony/console/exception/command_not_found_exception.rs @@ -1,13 +1,34 @@ +use super::exception_interface::ExceptionInterface; +use super::invalid_argument_exception::InvalidArgumentException; + #[derive(Debug)] pub struct CommandNotFoundException { - pub message: String, - pub code: i64, + inner: InvalidArgumentException, + alternatives: Vec<String>, +} + +impl CommandNotFoundException { + pub fn new(message: String, alternatives: Vec<String>, code: i64) -> Self { + Self { + inner: InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message, + code, + }), + alternatives, + } + } + + pub fn get_alternatives(&self) -> &Vec<String> { + &self.alternatives + } } impl std::fmt::Display for CommandNotFoundException { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) + write!(f, "{}", self.inner) } } impl std::error::Error for CommandNotFoundException {} + +impl ExceptionInterface for CommandNotFoundException {} diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/invalid_argument_exception.rs b/crates/shirabe-external-packages/src/symfony/console/exception/invalid_argument_exception.rs index 658ed8c..f4c8299 100644 --- a/crates/shirabe-external-packages/src/symfony/console/exception/invalid_argument_exception.rs +++ b/crates/shirabe-external-packages/src/symfony/console/exception/invalid_argument_exception.rs @@ -1,13 +1,14 @@ +use super::exception_interface::ExceptionInterface; + #[derive(Debug)] -pub struct InvalidArgumentException { - pub message: String, - pub code: i64, -} +pub struct InvalidArgumentException(pub shirabe_php_shim::InvalidArgumentException); impl std::fmt::Display for InvalidArgumentException { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) + write!(f, "{}", self.0) } } impl std::error::Error for InvalidArgumentException {} + +impl ExceptionInterface for InvalidArgumentException {} diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/invalid_option_exception.rs b/crates/shirabe-external-packages/src/symfony/console/exception/invalid_option_exception.rs new file mode 100644 index 0000000..b4c0cd8 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/exception/invalid_option_exception.rs @@ -0,0 +1,15 @@ +use super::exception_interface::ExceptionInterface; +use super::invalid_argument_exception::InvalidArgumentException; + +#[derive(Debug)] +pub struct InvalidOptionException(pub InvalidArgumentException); + +impl std::fmt::Display for InvalidOptionException { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for InvalidOptionException {} + +impl ExceptionInterface for InvalidOptionException {} diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/logic_exception.rs b/crates/shirabe-external-packages/src/symfony/console/exception/logic_exception.rs new file mode 100644 index 0000000..20870dc --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/exception/logic_exception.rs @@ -0,0 +1,14 @@ +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct LogicException(pub shirabe_php_shim::LogicException); + +impl std::fmt::Display for LogicException { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for LogicException {} + +impl ExceptionInterface for LogicException {} diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/missing_input_exception.rs b/crates/shirabe-external-packages/src/symfony/console/exception/missing_input_exception.rs new file mode 100644 index 0000000..60a984e --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/exception/missing_input_exception.rs @@ -0,0 +1,15 @@ +use super::exception_interface::ExceptionInterface; +use super::runtime_exception::RuntimeException; + +#[derive(Debug)] +pub struct MissingInputException(pub RuntimeException); + +impl std::fmt::Display for MissingInputException { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for MissingInputException {} + +impl ExceptionInterface for MissingInputException {} diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/mod.rs b/crates/shirabe-external-packages/src/symfony/console/exception/mod.rs index 25750a1..356b595 100644 --- a/crates/shirabe-external-packages/src/symfony/console/exception/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/exception/mod.rs @@ -1,7 +1,17 @@ pub mod command_not_found_exception; pub mod exception_interface; pub mod invalid_argument_exception; +pub mod invalid_option_exception; +pub mod logic_exception; +pub mod missing_input_exception; +pub mod namespace_not_found_exception; +pub mod runtime_exception; pub use command_not_found_exception::*; pub use exception_interface::*; pub use invalid_argument_exception::*; +pub use invalid_option_exception::*; +pub use logic_exception::*; +pub use missing_input_exception::*; +pub use namespace_not_found_exception::*; +pub use runtime_exception::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/namespace_not_found_exception.rs b/crates/shirabe-external-packages/src/symfony/console/exception/namespace_not_found_exception.rs new file mode 100644 index 0000000..53801bf --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/exception/namespace_not_found_exception.rs @@ -0,0 +1,15 @@ +use super::command_not_found_exception::CommandNotFoundException; +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct NamespaceNotFoundException(pub CommandNotFoundException); + +impl std::fmt::Display for NamespaceNotFoundException { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for NamespaceNotFoundException {} + +impl ExceptionInterface for NamespaceNotFoundException {} diff --git a/crates/shirabe-external-packages/src/symfony/console/exception/runtime_exception.rs b/crates/shirabe-external-packages/src/symfony/console/exception/runtime_exception.rs new file mode 100644 index 0000000..9efabed --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/exception/runtime_exception.rs @@ -0,0 +1,14 @@ +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct RuntimeException(pub shirabe_php_shim::RuntimeException); + +impl std::fmt::Display for RuntimeException { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for RuntimeException {} + +impl ExceptionInterface for RuntimeException {} diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs index 0431601..716d3fb 100644 --- a/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs @@ -1,7 +1,13 @@ pub mod output_formatter; pub mod output_formatter_interface; pub mod output_formatter_style; +pub mod output_formatter_style_interface; +pub mod output_formatter_style_stack; +pub mod wrappable_output_formatter_interface; pub use output_formatter::*; pub use output_formatter_interface::*; pub use output_formatter_style::*; +pub use output_formatter_style_interface::*; +pub use output_formatter_style_stack::*; +pub use wrappable_output_formatter_interface::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs index f64361e..413f023 100644 --- a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs +++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs @@ -1,83 +1,347 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::formatter::output_formatter_interface::OutputFormatterInterface; +use crate::symfony::console::formatter::output_formatter_style::OutputFormatterStyle; +use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; +use crate::symfony::console::formatter::output_formatter_style_stack::OutputFormatterStyleStack; +use crate::symfony::console::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface; +use crate::symfony::string::b; + +/// Formatter class for console output. #[derive(Debug)] -pub struct OutputFormatter; +pub struct OutputFormatter { + decorated: bool, + styles: indexmap::IndexMap<String, Box<dyn OutputFormatterStyleInterface>>, + style_stack: OutputFormatterStyleStack, +} impl OutputFormatter { - pub fn new( - _decorated: bool, - _styles: indexmap::IndexMap< - String, - crate::symfony::console::formatter::OutputFormatterStyle, - >, - ) -> Self { - todo!() - } + /// Escapes "<" and ">" special chars in given text. + pub fn escape(text: &str) -> anyhow::Result<String> { + let text = shirabe_php_shim::preg_replace("/([^\\\\]|^)([<>])/", "$1\\\\$2", text) + .expect("preg_replace failed"); - pub fn format(&self, _message: &str) -> String { - todo!() + Ok(Self::escape_trailing_backslash(&text)) } - pub fn is_decorated(&self) -> bool { - todo!() + /// Escapes trailing "\" in given text. + pub fn escape_trailing_backslash(text: &str) -> String { + let mut text = text.to_string(); + if shirabe_php_shim::str_ends_with(&text, "\\") { + let len = shirabe_php_shim::strlen(&text); + text = shirabe_php_shim::rtrim(&text, Some("\\")); + text = shirabe_php_shim::str_replace("\0", "", &text); + text.push_str(&shirabe_php_shim::str_repeat( + "\0", + (len - shirabe_php_shim::strlen(&text)) as usize, + )); + } + + text } - pub fn set_decorated(&mut self, _decorated: bool) { - todo!() + /// Initializes console output formatter. + /// + /// `styles` is an array of "name => FormatterStyle" instances. + pub fn new( + decorated: bool, + styles: indexmap::IndexMap<String, Box<dyn OutputFormatterStyleInterface>>, + ) -> Self { + let mut this = Self { + decorated, + styles: indexmap::IndexMap::new(), + style_stack: OutputFormatterStyleStack::new(None), + }; + + this.set_style( + "error", + Box::new(OutputFormatterStyle::new( + Some("white"), + Some("red"), + vec![], + )), + ); + this.set_style( + "info", + Box::new(OutputFormatterStyle::new(Some("green"), None, vec![])), + ); + this.set_style( + "comment", + Box::new(OutputFormatterStyle::new(Some("yellow"), None, vec![])), + ); + this.set_style( + "question", + Box::new(OutputFormatterStyle::new( + Some("black"), + Some("cyan"), + vec![], + )), + ); + + for (name, style) in styles { + this.set_style(&name, style); + } + + this.style_stack = OutputFormatterStyleStack::new(None); + + this } - pub fn escape(_text: &str) -> String { - todo!() + pub fn get_style_stack(&self) -> &OutputFormatterStyleStack { + &self.style_stack } - pub fn escape_trailing_backslash(_text: &str) -> String { - todo!() + /// Tries to create new style instance from string. + fn create_style_from_string( + &self, + string: &str, + ) -> anyhow::Result<Option<Box<dyn OutputFormatterStyleInterface>>> { + if self.styles.contains_key(string) { + // PHP returns the shared style instance; ownership cannot be expressed without Clone + // on the trait object. + // TODO(human-review): returning a shared style here needs an Rc/Clone strategy in + // Phase C. + return Ok(Some(todo!())); + } + + let mut matches: Vec<Vec<String>> = vec![]; + if shirabe_php_shim::preg_match_all_set_order( + "/([^=]+)=([^;]+)(;|$)/", + string, + &mut matches, + )? == 0 + { + return Ok(None); + } + + let mut style = OutputFormatterStyle::new(None, None, vec![]); + for r#match in &matches { + let mut r#match: Vec<String> = r#match.clone(); + shirabe_php_shim::array_shift(&mut r#match); + r#match[0] = shirabe_php_shim::strtolower(&r#match[0]); + + if r#match[0] == "fg" { + style.set_foreground(Some(&shirabe_php_shim::strtolower(&r#match[1]))); + } else if r#match[0] == "bg" { + style.set_background(Some(&shirabe_php_shim::strtolower(&r#match[1]))); + } else if r#match[0] == "href" { + let url = shirabe_php_shim::preg_replace("{\\\\([<>])}", "$1", &r#match[1]) + .expect("preg_replace failed"); + style.set_href(&url); + } else if r#match[0] == "options" { + let mut options: Vec<Vec<String>> = vec![]; + shirabe_php_shim::preg_match_all_simple( + "([^,;]+)", + &shirabe_php_shim::strtolower(&r#match[1]), + &mut options, + )?; + let options = shirabe_php_shim::array_shift(&mut options).unwrap_or_default(); + for option in &options { + style.set_option(option); + } + } else { + return Ok(None); + } + } + + Ok(Some(Box::new(style))) } - pub fn set_style( + /// Applies current style from stack to text, if must be applied. + fn apply_current_style( &mut self, - _name: &str, - _style: crate::symfony::console::formatter::OutputFormatterStyle, - ) { - todo!() - } + text: &str, + current: &str, + width: i64, + current_line_length: &mut i64, + ) -> String { + if text.is_empty() { + return String::new(); + } - pub fn has_style(&self, _name: &str) -> bool { - todo!() + if width == 0 { + return if self.is_decorated() { + self.style_stack.get_current_mut().apply(text) + } else { + text.to_string() + }; + } + + let mut text = text.to_string(); + + if *current_line_length == 0 && !current.is_empty() { + text = shirabe_php_shim::ltrim(&text, None); + } + + let prefix; + if *current_line_length != 0 { + let i = width - *current_line_length; + prefix = format!("{}\n", shirabe_php_shim::substr(&text, 0, Some(i))); + text = shirabe_php_shim::substr(&text, i, None); + } else { + prefix = String::new(); + } + + let mut matches: Vec<Option<String>> = vec![]; + shirabe_php_shim::preg_match("~(\\n)$~", &text, &mut matches); + text = format!("{}{}", prefix, self.add_line_breaks(&text, width)); + let trailing = matches.get(1).and_then(|m| m.clone()).unwrap_or_default(); + text = format!("{}{}", shirabe_php_shim::rtrim(&text, Some("\n")), trailing); + + if *current_line_length == 0 + && !current.is_empty() + && shirabe_php_shim::substr(current, -1, None) != "\n" + { + text = format!("\n{text}"); + } + + let mut lines = shirabe_php_shim::explode("\n", &text); + + for line in &lines { + *current_line_length += shirabe_php_shim::strlen(line); + if width <= *current_line_length { + *current_line_length = 0; + } + } + + if self.is_decorated() { + for line in lines.iter_mut() { + *line = self.style_stack.get_current_mut().apply(line); + } + } + + shirabe_php_shim::implode("\n", &lines) } - pub fn get_style( - &self, - _name: &str, - ) -> crate::symfony::console::formatter::OutputFormatterStyle { - todo!() + fn add_line_breaks(&self, text: &str, width: i64) -> String { + let encoding = shirabe_php_shim::mb_detect_encoding(text, None, true) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "UTF-8".to_string()); + + b(text) + .to_code_point_string(&encoding) + .wordwrap(width, "\n", true) + .to_byte_string(&encoding) } } -impl crate::symfony::console::formatter::OutputFormatterInterface for OutputFormatter { - fn is_decorated(&self) -> bool { - self.is_decorated() +impl OutputFormatterInterface for OutputFormatter { + fn set_decorated(&mut self, decorated: bool) { + self.decorated = decorated; } - fn set_decorated(&mut self, decorated: bool) { - self.set_decorated(decorated) + fn is_decorated(&self) -> bool { + self.decorated } - fn set_style( - &mut self, - name: &str, - style: crate::symfony::console::formatter::OutputFormatterStyle, - ) { - self.set_style(name, style) + fn set_style(&mut self, name: &str, style: Box<dyn OutputFormatterStyleInterface>) { + self.styles + .insert(shirabe_php_shim::strtolower(name), style); } fn has_style(&self, name: &str) -> bool { - self.has_style(name) + self.styles + .contains_key(&shirabe_php_shim::strtolower(name)) } - fn get_style(&self, name: &str) -> crate::symfony::console::formatter::OutputFormatterStyle { - self.get_style(name) + fn get_style(&self, name: &str) -> anyhow::Result<Box<dyn OutputFormatterStyleInterface>> { + if !self.has_style(name) { + return Err(anyhow::anyhow!(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Undefined style: \"%s\".", + &[shirabe_php_shim::PhpMixed::String(name.to_string())], + ), + code: 0, + }, + ))); + } + + // PHP returns the shared style instance; ownership cannot be expressed without Clone on + // the trait object. + // TODO(human-review): returning a shared style here needs an Rc/Clone strategy in Phase C. + let _ = &self.styles[&shirabe_php_shim::strtolower(name)]; + todo!() } - fn format(&self, message: &str) -> String { - self.format(message) + fn format(&mut self, message: Option<&str>) -> anyhow::Result<Option<String>> { + self.format_and_wrap(message, 0) + } +} + +impl WrappableOutputFormatterInterface for OutputFormatter { + fn format_and_wrap( + &mut self, + message: Option<&str>, + width: i64, + ) -> anyhow::Result<Option<String>> { + let message = match message { + None => return Ok(Some(String::new())), + Some(message) => message, + }; + + let mut offset: i64 = 0; + let mut output = String::new(); + let open_tag_regex = "[a-z](?:[^\\\\<>]*+ | \\\\.)*"; + let close_tag_regex = "[a-z][^<>]*+"; + let mut current_line_length: i64 = 0; + let mut matches: shirabe_php_shim::PregOffsetCaptureMatches = Default::default(); + shirabe_php_shim::preg_match_all_offset_capture( + &format!("#<(({open_tag_regex}) | /({close_tag_regex})?)>#ix"), + message, + &mut matches, + )?; + let count = matches.group(0).len(); + for i in 0..count { + let (text, pos) = matches.group(0)[i].clone(); + let pos = pos as i64; + + if pos != 0 && shirabe_php_shim::byte_at(message, (pos - 1) as usize) == b'\\' { + continue; + } + + // add the text up to the next tag + let segment = shirabe_php_shim::substr(message, offset, Some(pos - offset)); + let applied = + self.apply_current_style(&segment, &output, width, &mut current_line_length); + output.push_str(&applied); + offset = pos + shirabe_php_shim::strlen(&text); + + // opening tag? + let open = shirabe_php_shim::byte_at(&text, 1) != b'/'; + let tag = if open { + matches.group(1)[i].0.clone() + } else { + matches + .group(3) + .get(i) + .map(|m| m.0.clone()) + .unwrap_or_default() + }; + + if !open && tag.is_empty() { + // </> + self.style_stack.pop(None)?.ok(); + } else if let Some(style) = self.create_style_from_string(&tag)? { + if open { + self.style_stack.push(style); + } else { + self.style_stack.pop(Some(style))?.ok(); + } + } else { + let applied = + self.apply_current_style(&text, &output, width, &mut current_line_length); + output.push_str(&applied); + } + } + + let segment = shirabe_php_shim::substr(message, offset, None); + let applied = self.apply_current_style(&segment, &output, width, &mut current_line_length); + output.push_str(&applied); + + let mut pairs = indexmap::IndexMap::new(); + pairs.insert("\0".to_string(), "\\".to_string()); + pairs.insert("\\<".to_string(), "<".to_string()); + pairs.insert("\\>".to_string(), ">".to_string()); + Ok(Some(shirabe_php_shim::strtr_array(&output, &pairs))) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs index 7f6e9b0..03d728c 100644 --- a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs @@ -1,10 +1,24 @@ -use crate::symfony::console::formatter::OutputFormatterStyle; +use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; +/// Formatter interface for console output. pub trait OutputFormatterInterface { - fn is_decorated(&self) -> bool; + /// Sets the decorated flag. fn set_decorated(&mut self, decorated: bool); - fn set_style(&mut self, name: &str, style: OutputFormatterStyle); + + /// Whether the output will decorate messages. + fn is_decorated(&self) -> bool; + + /// Sets a new style. + fn set_style(&mut self, name: &str, style: Box<dyn OutputFormatterStyleInterface>); + + /// Checks if output formatter has style with specified name. fn has_style(&self, name: &str) -> bool; - fn get_style(&self, name: &str) -> OutputFormatterStyle; - fn format(&self, message: &str) -> String; + + /// Gets style options from style with specified name. + /// + /// Throws InvalidArgumentException when style isn't defined. + fn get_style(&self, name: &str) -> anyhow::Result<Box<dyn OutputFormatterStyleInterface>>; + + /// Formats a message according to the given styles. + fn format(&mut self, message: Option<&str>) -> anyhow::Result<Option<String>>; } diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs index 6299157..835bcd6 100644 --- a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs +++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs @@ -1,12 +1,96 @@ +use crate::symfony::console::color::Color; +use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; + +/// Formatter style class for defining styles. #[derive(Debug)] -pub struct OutputFormatterStyle; +pub struct OutputFormatterStyle { + color: Color, + foreground: String, + background: String, + options: Vec<String>, + href: Option<String>, + handles_href_gracefully: Option<bool>, +} impl OutputFormatterStyle { - pub fn new( - _foreground: Option<&str>, - _background: Option<&str>, - _options: Option<Vec<String>>, - ) -> Self { - todo!() + /// Initializes output formatter style. + pub fn new(foreground: Option<&str>, background: Option<&str>, options: Vec<String>) -> Self { + let foreground = foreground + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(); + let background = background + .filter(|s| !s.is_empty()) + .unwrap_or("") + .to_string(); + let color = Color::new(&foreground, &background, &options.clone()).unwrap(); + Self { + color, + foreground, + background, + options, + href: None, + handles_href_gracefully: None, + } + } + + pub fn set_href(&mut self, url: &str) { + self.href = Some(url.to_string()); + } +} + +impl OutputFormatterStyleInterface for OutputFormatterStyle { + fn set_foreground(&mut self, color: Option<&str>) { + self.foreground = color.filter(|s| !s.is_empty()).unwrap_or("").to_string(); + self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap(); + } + + fn set_background(&mut self, color: Option<&str>) { + self.background = color.filter(|s| !s.is_empty()).unwrap_or("").to_string(); + self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap(); + } + + fn set_option(&mut self, option: &str) { + self.options.push(option.to_string()); + self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap(); + } + + fn unset_option(&mut self, option: &str) { + let pos = shirabe_php_shim::array_search_in_vec(option, &self.options); + if let Some(pos) = pos { + self.options.remove(pos); + } + + self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap(); + } + + fn set_options(&mut self, options: Vec<String>) { + self.options = options; + self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap(); + } + + fn apply(&mut self, text: &str) -> String { + let mut text = text.to_string(); + + if self.handles_href_gracefully.is_none() { + self.handles_href_gracefully = Some( + shirabe_php_shim::getenv("TERMINAL_EMULATOR").as_deref() + != Some("JetBrains-JediTerm") + && (shirabe_php_shim::getenv("KONSOLE_VERSION").is_none_or(|v| v.is_empty()) + || shirabe_php_shim::getenv("KONSOLE_VERSION") + .map(|v| v.parse::<i64>().unwrap_or(0)) + .unwrap_or(0) + > 201100) + && !shirabe_php_shim::server_contains_key("IDEA_INITIAL_DIRECTORY"), + ); + } + + if let Some(href) = &self.href { + if self.handles_href_gracefully == Some(true) { + text = format!("\x1b]8;;{href}\x1b\\{text}\x1b]8;;\x1b\\"); + } + } + + self.color.apply(&text) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_interface.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_interface.rs new file mode 100644 index 0000000..c9a2648 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_interface.rs @@ -0,0 +1,20 @@ +/// Formatter style interface for defining styles. +pub trait OutputFormatterStyleInterface: std::fmt::Debug { + /// Sets style foreground color. + fn set_foreground(&mut self, color: Option<&str>); + + /// Sets style background color. + fn set_background(&mut self, color: Option<&str>); + + /// Sets some specific style option. + fn set_option(&mut self, option: &str); + + /// Unsets some specific style option. + fn unset_option(&mut self, option: &str); + + /// Sets multiple style options at once. + fn set_options(&mut self, options: Vec<String>); + + /// Applies the style to a given text. + fn apply(&mut self, text: &str) -> String; +} diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_stack.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_stack.rs new file mode 100644 index 0000000..2b290db --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_stack.rs @@ -0,0 +1,108 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::formatter::output_formatter_style::OutputFormatterStyle; +use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; +use crate::symfony::contracts::service::reset_interface::ResetInterface; + +#[derive(Debug)] +pub struct OutputFormatterStyleStack { + styles: Vec<Box<dyn OutputFormatterStyleInterface>>, + empty_style: Box<dyn OutputFormatterStyleInterface>, +} + +impl OutputFormatterStyleStack { + pub fn new(empty_style: Option<Box<dyn OutputFormatterStyleInterface>>) -> Self { + let empty_style = + empty_style.unwrap_or_else(|| Box::new(OutputFormatterStyle::new(None, None, vec![]))); + let mut this = Self { + styles: vec![], + empty_style, + }; + this.reset(); + this + } + + /// Pushes a style in the stack. + pub fn push(&mut self, style: Box<dyn OutputFormatterStyleInterface>) { + self.styles.push(style); + } + + /// Pops a style from the stack. + /// + /// Throws InvalidArgumentException when style tags incorrectly nested. + pub fn pop( + &mut self, + mut style: Option<Box<dyn OutputFormatterStyleInterface>>, + ) -> anyhow::Result<Result<Box<dyn OutputFormatterStyleInterface>, InvalidArgumentException>> + { + if self.styles.is_empty() { + // Returns the shared empty style; ownership cannot be expressed without Clone on the + // trait object. + // TODO(human-review): empty-style branch needs a Clone/Rc strategy in Phase C. + return Ok(Ok(todo!())); + } + + let style = match style.as_mut() { + None => { + return Ok(Ok(shirabe_php_shim::array_pop(&mut self.styles).unwrap())); + } + Some(style) => style, + }; + + for index in (0..self.styles.len()).rev() { + if style.apply("") == self.styles[index].apply("") { + // PHP: array_slice($this->styles, 0, $index) keeps elements before $index, + // dropping the matched element and everything after it. + let stacked_style = self.styles.remove(index); + self.styles.truncate(index); + + return Ok(Ok(stacked_style)); + } + } + + Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "Incorrectly nested style tag found.".to_string(), + code: 0, + }, + ))) + } + + /// Computes current style with stacks top codes. + pub fn get_current(&self) -> &dyn OutputFormatterStyleInterface { + if self.styles.is_empty() { + return self.empty_style.as_ref(); + } + + self.styles[self.styles.len() - 1].as_ref() + } + + /// Mutable variant of `get_current`, needed because `apply` lazily mutates style state. + pub fn get_current_mut(&mut self) -> &mut dyn OutputFormatterStyleInterface { + if self.styles.is_empty() { + return self.empty_style.as_mut(); + } + + let last = self.styles.len() - 1; + self.styles[last].as_mut() + } + + pub fn set_empty_style( + &mut self, + empty_style: Box<dyn OutputFormatterStyleInterface>, + ) -> &mut Self { + self.empty_style = empty_style; + + self + } + + pub fn get_empty_style(&self) -> &dyn OutputFormatterStyleInterface { + self.empty_style.as_ref() + } +} + +impl ResetInterface for OutputFormatterStyleStack { + /// Resets stack (ie. empty internal arrays). + fn reset(&mut self) { + self.styles = vec![]; + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/wrappable_output_formatter_interface.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/wrappable_output_formatter_interface.rs new file mode 100644 index 0000000..be8b466 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/formatter/wrappable_output_formatter_interface.rs @@ -0,0 +1,11 @@ +use crate::symfony::console::formatter::output_formatter_interface::OutputFormatterInterface; + +/// Formatter interface for console output that supports word wrapping. +pub trait WrappableOutputFormatterInterface: OutputFormatterInterface { + /// Formats a message according to the given styles, wrapping at `width` (0 means no wrapping). + fn format_and_wrap( + &mut self, + message: Option<&str>, + width: i64, + ) -> anyhow::Result<Option<String>>; +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/debug_formatter_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/debug_formatter_helper.rs new file mode 100644 index 0000000..26ad169 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/debug_formatter_helper.rs @@ -0,0 +1,194 @@ +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use indexmap::IndexMap; +use std::cell::RefCell; +use std::rc::Rc; + +const COLORS: [&str; 9] = [ + "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "default", +]; + +/// Helps outputting debug information when running an external program from a command. +/// +/// An external program can be a Process, an HTTP request, or anything else. +#[derive(Debug)] +pub struct DebugFormatterHelper { + inner: Helper, + started: IndexMap<String, DebugFormatterSession>, + count: i64, +} + +/// Per-id session state. PHP stores this as `['border' => int, 'out' => true, 'err' => true]` +/// where presence of `out`/`err` keys is tested via `isset` and removed via `unset`. +#[derive(Debug, Default)] +struct DebugFormatterSession { + border: i64, + out: bool, + err: bool, +} + +impl Default for DebugFormatterHelper { + fn default() -> Self { + Self { + inner: Helper::default(), + started: IndexMap::new(), + count: -1, + } + } +} + +impl DebugFormatterHelper { + /// Starts a debug formatting session. + pub fn start(&mut self, id: &str, message: &str, prefix: &str) -> String { + self.count += 1; + self.started.insert( + id.to_string(), + DebugFormatterSession { + border: self.count % COLORS.len() as i64, + out: false, + err: false, + }, + ); + + shirabe_php_shim::sprintf( + "%s<bg=blue;fg=white> %s </> <fg=blue>%s</>\n", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ) + } + + /// Adds progress to a formatting session. + pub fn progress( + &mut self, + id: &str, + buffer: &str, + error: bool, + prefix: &str, + error_prefix: &str, + ) -> String { + let mut message = String::new(); + + if error { + if self.started[id].out { + message.push('\n'); + self.started.get_mut(id).unwrap().out = false; + } + if !self.started[id].err { + message.push_str(&shirabe_php_shim::sprintf( + "%s<bg=red;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(error_prefix.to_string()), + ], + )); + self.started.get_mut(id).unwrap().err = true; + } + + message.push_str(&shirabe_php_shim::str_replace( + "\n", + &shirabe_php_shim::sprintf( + "\n%s<bg=red;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(error_prefix.to_string()), + ], + ), + buffer, + )); + } else { + if self.started[id].err { + message.push('\n'); + self.started.get_mut(id).unwrap().err = false; + } + if !self.started[id].out { + message.push_str(&shirabe_php_shim::sprintf( + "%s<bg=green;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + ], + )); + self.started.get_mut(id).unwrap().out = true; + } + + message.push_str(&shirabe_php_shim::str_replace( + "\n", + &shirabe_php_shim::sprintf( + "\n%s<bg=green;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + ], + ), + buffer, + )); + } + + message + } + + /// Stops a formatting session. + pub fn stop(&mut self, id: &str, message: &str, successful: bool, prefix: &str) -> String { + let trailing_eol = if self.started[id].out || self.started[id].err { + "\n" + } else { + "" + }; + + if successful { + return shirabe_php_shim::sprintf( + "%s%s<bg=green;fg=white> %s </> <fg=green>%s</>\n", + &[ + shirabe_php_shim::PhpMixed::String(trailing_eol.to_string()), + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ); + } + + let message = shirabe_php_shim::sprintf( + "%s%s<bg=red;fg=white> %s </> <fg=red>%s</>\n", + &[ + shirabe_php_shim::PhpMixed::String(trailing_eol.to_string()), + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ); + + if let Some(session) = self.started.get_mut(id) { + session.out = false; + session.err = false; + } + + message + } + + fn get_border(&self, id: &str) -> String { + shirabe_php_shim::sprintf( + "<bg=%s> </>", + &[shirabe_php_shim::PhpMixed::String( + COLORS[self.started[id].border as usize].to_string(), + )], + ) + } +} + +impl HelperInterface for DebugFormatterHelper { + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "debug_formatter".to_string() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/descriptor_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/descriptor_helper.rs new file mode 100644 index 0000000..64c166c --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/descriptor_helper.rs @@ -0,0 +1,135 @@ +use crate::symfony::console::descriptor::descriptor_interface::DescriptorInterface; +use crate::symfony::console::descriptor::json_descriptor::JsonDescriptor; +use crate::symfony::console::descriptor::markdown_descriptor::MarkdownDescriptor; +use crate::symfony::console::descriptor::text_descriptor::TextDescriptor; +use crate::symfony::console::descriptor::xml_descriptor::XmlDescriptor; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use std::cell::RefCell; +use std::rc::Rc; + +/// This class adds helper method to describe objects in various formats. +#[derive(Default)] +pub struct DescriptorHelper { + inner: Helper, + /// @var DescriptorInterface[] + descriptors: IndexMap<String, Box<dyn DescriptorInterface>>, +} + +// `DescriptorInterface` does not require `Debug`, so the derive cannot see +// through the trait object; provide a minimal manual impl. +impl std::fmt::Debug for DescriptorHelper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DescriptorHelper") + .field("inner", &self.inner) + .field("descriptors", &self.descriptors.keys().collect::<Vec<_>>()) + .finish() + } +} + +impl DescriptorHelper { + pub fn new() -> Self { + let mut this = Self { + inner: Helper::default(), + descriptors: IndexMap::new(), + }; + // The XML/JSON/Markdown descriptors do not yet expose a constructor + // (no `Default`/`new`); their construction is deferred until those + // descriptor types provide one. + let xml: Box<dyn DescriptorInterface> = todo!(); + let json: Box<dyn DescriptorInterface> = todo!(); + let md: Box<dyn DescriptorInterface> = todo!(); + this.register("txt", Box::new(TextDescriptor::default())) + .register("xml", xml) + .register("json", json) + .register("md", md); + this + } + + /// Describes an object if supported. + /// + /// Available options are: + /// * format: string, the output format name + /// * raw_text: boolean, sets output type as raw + /// + /// @throws InvalidArgumentException when the given format is not supported + pub fn describe2( + &self, + output: &dyn OutputInterface, + object: Option<shirabe_php_shim::PhpMixed>, + options: IndexMap<String, shirabe_php_shim::PhpMixed>, + ) -> Result<(), InvalidArgumentException> { + let mut merged: IndexMap<String, shirabe_php_shim::PhpMixed> = IndexMap::new(); + merged.insert( + "raw_text".to_string(), + shirabe_php_shim::PhpMixed::Bool(false), + ); + merged.insert( + "format".to_string(), + shirabe_php_shim::PhpMixed::String("txt".to_string()), + ); + for (key, value) in options { + merged.insert(key, value); + } + let options = merged; + + let format = match &options["format"] { + shirabe_php_shim::PhpMixed::String(format) => format.clone(), + _ => String::new(), + }; + + if !self.descriptors.contains_key(&format) { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Unsupported format \"%s\".", + &[shirabe_php_shim::PhpMixed::String(format.clone())], + ), + code: 0, + }, + )); + } + + let _ = (&self.descriptors[&format], output, object, options); + // `DescriptorInterface::describe` takes an owned + // `Rc<RefCell<dyn OutputInterface>>` and `&mut self`, while the helper + // only holds a shared `&dyn OutputInterface` and `&self`. Reconciling + // this ownership/mutability gap is a Phase C decision. + todo!("dispatch to DescriptorInterface::describe (Phase C ownership)"); + } + + /// Registers a descriptor. + /// + /// @return $this + pub fn register( + &mut self, + format: &str, + descriptor: Box<dyn DescriptorInterface>, + ) -> &mut Self { + self.descriptors.insert(format.to_string(), descriptor); + + self + } + + pub fn get_formats(&self) -> Vec<String> { + self.descriptors.keys().cloned().collect() + } +} + +impl HelperInterface for DescriptorHelper { + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "descriptor".to_string() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs index e55dbce..ef33f93 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs @@ -1,20 +1,113 @@ -use crate::symfony::console::helper::HelperInterface; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use std::cell::RefCell; +use std::rc::Rc; -#[derive(Debug)] -pub struct FormatterHelper; +/// The Formatter class provides helpers to format messages. +#[derive(Debug, Default)] +pub struct FormatterHelper { + inner: Helper, +} impl FormatterHelper { - pub fn format_section(&self, _section: &str, _message: &str, _style: &str) -> String { - todo!() + /// Formats a message within a section. + pub fn format_section(&self, section: &str, message: &str, style: &str) -> String { + shirabe_php_shim::sprintf( + "<%s>[%s]</%s> %s", + &[ + shirabe_php_shim::PhpMixed::String(style.to_string()), + shirabe_php_shim::PhpMixed::String(section.to_string()), + shirabe_php_shim::PhpMixed::String(style.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ) } - pub fn format_block(&self, _messages: &[&str], _style: &str, _large: bool) -> String { - todo!() + /// Formats a message as a block of text. + /// + /// @param string|array $messages The message to write in the block + pub fn format_block(&self, messages: FormatBlockMessages, style: &str, large: bool) -> String { + let messages = match messages { + FormatBlockMessages::String(message) => vec![message], + FormatBlockMessages::Array(messages) => messages, + }; + + let mut len: i64 = 0; + let mut lines: Vec<String> = Vec::new(); + for message in &messages { + let message = OutputFormatter::escape(message).unwrap(); + lines.push(shirabe_php_shim::sprintf( + if large { " %s " } else { " %s " }, + &[shirabe_php_shim::PhpMixed::String(message.clone())], + )); + len = std::cmp::max(Helper::width(&message) + (if large { 4 } else { 2 }), len); + } + + let mut messages: Vec<String> = if large { + vec![shirabe_php_shim::str_repeat(" ", len as usize)] + } else { + vec![] + }; + let mut i = 0; + while i < lines.len() { + messages.push(format!( + "{}{}", + lines[i], + shirabe_php_shim::str_repeat(" ", (len - Helper::width(&lines[i])) as usize) + )); + i += 1; + } + if large { + messages.push(shirabe_php_shim::str_repeat(" ", len as usize)); + } + + let mut i = 0; + while i < messages.len() { + messages[i] = shirabe_php_shim::sprintf( + "<%s>%s</%s>", + &[ + shirabe_php_shim::PhpMixed::String(style.to_string()), + shirabe_php_shim::PhpMixed::String(messages[i].clone()), + shirabe_php_shim::PhpMixed::String(style.to_string()), + ], + ); + i += 1; + } + + messages.join("\n") + } + + /// Truncates a message to the given length. + pub fn truncate(&self, message: &str, length: i64, suffix: &str) -> String { + let computed_length = length - Helper::width(suffix); + + if computed_length > Helper::width(message) { + return message.to_string(); + } + + format!("{}{}", Helper::substr(message, 0, Some(length)), suffix) } } impl HelperInterface for FormatterHelper { - fn as_any(&self) -> &dyn std::any::Any { - self + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "formatter".to_string() } } + +/// `formatBlock` accepts either a single string or an array of strings. +#[derive(Debug)] +pub enum FormatBlockMessages { + String(String), + Array(Vec<String>), +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs index 881aabd..b599f11 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs @@ -1,3 +1,171 @@ -pub trait Helper { - fn get_name(&self) -> String; +use crate::symfony::console::formatter::output_formatter_interface::OutputFormatterInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::string::unicode_string::UnicodeString; +use std::cell::RefCell; +use std::rc::Rc; + +/// Helper is the base class for all helper classes. +#[derive(Debug, Default)] +pub struct Helper { + pub(crate) helper_set: Option<Rc<RefCell<HelperSet>>>, +} + +impl Helper { + pub fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.helper_set = helper_set; + } + + pub fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.helper_set.clone() + } + + /// Returns the length of a string, using mb_strwidth if it is available. + /// + /// @deprecated since Symfony 5.3 + pub fn strlen(string: &str) -> i64 { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.3", + "Method \"%s()\" is deprecated and will be removed in Symfony 6.0. Use Helper::width() or Helper::length() instead.", + "Helper::strlen", + ); + + Self::width(string) + } + + /// Returns the width of a string, using mb_strwidth if it is available. + /// The width is how many characters positions the string will use. + pub fn width(string: &str) -> i64 { + if shirabe_php_shim::preg_match("//u", string, &mut Vec::new()) != 0 { + return UnicodeString::new(string).width(false); + } + + let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true); + let encoding = match encoding { + Some(encoding) => encoding, + None => return shirabe_php_shim::strlen(string), + }; + + shirabe_php_shim::mb_strwidth(string, Some(&encoding)) + } + + /// Returns the length of a string, using mb_strlen if it is available. + /// The length is related to how many bytes the string will use. + pub fn length(string: &str) -> i64 { + if shirabe_php_shim::preg_match("//u", string, &mut Vec::new()) != 0 { + return UnicodeString::new(string).length(); + } + + let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true); + let encoding = match encoding { + Some(encoding) => encoding, + None => return shirabe_php_shim::strlen(string), + }; + + shirabe_php_shim::mb_strlen(string, &encoding) + } + + /// Returns the subset of a string, using mb_substr if it is available. + pub fn substr(string: &str, from: i64, length: Option<i64>) -> String { + let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true); + let encoding = match encoding { + Some(encoding) => encoding, + None => return shirabe_php_shim::substr(string, from, length), + }; + + shirabe_php_shim::mb_substr(string, from, length, Some(&encoding)) + } + + pub fn format_time(secs: f64) -> Option<String> { + // [threshold, label, divisor?] + let time_formats: [(f64, &str, Option<f64>); 9] = [ + (0.0, "< 1 sec", None), + (1.0, "1 sec", None), + (2.0, "secs", Some(1.0)), + (60.0, "1 min", None), + (120.0, "mins", Some(60.0)), + (3600.0, "1 hr", None), + (7200.0, "hrs", Some(3600.0)), + (86400.0, "1 day", None), + (172800.0, "days", Some(86400.0)), + ]; + + for (index, format) in time_formats.iter().enumerate() { + if secs >= format.0 { + if (index + 1 < time_formats.len() && secs < time_formats[index + 1].0) + || index == time_formats.len() - 1 + { + match format.2 { + None => return Some(format.1.to_string()), + Some(divisor) => { + return Some(format!("{} {}", (secs / divisor).floor(), format.1)); + } + } + } + } + } + + None + } + + pub fn format_memory(memory: i64) -> String { + if memory >= 1024 * 1024 * 1024 { + return shirabe_php_shim::sprintf( + "%.1f GiB", + &[shirabe_php_shim::PhpMixed::Float( + memory as f64 / 1024.0 / 1024.0 / 1024.0, + )], + ); + } + + if memory >= 1024 * 1024 { + return shirabe_php_shim::sprintf( + "%.1f MiB", + &[shirabe_php_shim::PhpMixed::Float( + memory as f64 / 1024.0 / 1024.0, + )], + ); + } + + if memory >= 1024 { + return shirabe_php_shim::sprintf( + "%d KiB", + &[shirabe_php_shim::PhpMixed::Int(memory / 1024)], + ); + } + + shirabe_php_shim::sprintf("%d B", &[shirabe_php_shim::PhpMixed::Int(memory)]) + } + + /// @deprecated since Symfony 5.3 + pub fn strlen_without_decoration( + formatter: &mut dyn OutputFormatterInterface, + string: &str, + ) -> i64 { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.3", + "Method \"%s()\" is deprecated and will be removed in Symfony 6.0. Use Helper::removeDecoration() instead.", + "Helper::strlenWithoutDecoration", + ); + + Self::width(&Self::remove_decoration(formatter, string)) + } + + pub fn remove_decoration(formatter: &mut dyn OutputFormatterInterface, string: &str) -> String { + let is_decorated = formatter.is_decorated(); + formatter.set_decorated(false); + // remove <...> formatting + let string = formatter.format(Some(string)).unwrap().unwrap_or_default(); + // remove already formatted characters + let string = + shirabe_php_shim::preg_replace("/\u{1b}\\[[^m]*m/", "", &string).unwrap_or_default(); + // remove terminal hyperlinks + let string = + shirabe_php_shim::preg_replace("/\u{1b}]8;[^;]*;[^\u{1b}]*\u{1b}\\\\/", "", &string) + .unwrap_or_default(); + formatter.set_decorated(is_decorated); + + string + } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs b/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs index cf60bf0..f7481fd 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs @@ -1,17 +1,15 @@ -use crate::symfony::console::helper::HelperSet; +use crate::symfony::console::helper::helper_set::HelperSet; +use std::cell::RefCell; +use std::rc::Rc; -pub trait HelperInterface: std::fmt::Debug { - fn set_helper_set(&mut self, _helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>) { - todo!() - } +/// HelperInterface is the interface all helpers must implement. +pub trait HelperInterface: std::fmt::Debug + shirabe_php_shim::AsAny { + /// Sets the helper set associated with this helper. + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>); - fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::RefCell<HelperSet>>> { - todo!() - } + /// Gets the helper set associated with this helper. + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>>; - fn get_name(&self) -> String { - todo!() - } - - fn as_any(&self) -> &dyn std::any::Any; + /// Returns the canonical name of this helper. + fn get_name(&self) -> String; } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs b/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs index e38dbf3..a7ef820 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs @@ -1,27 +1,115 @@ -use crate::symfony::console::helper::HelperInterface; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::helper_interface::HelperInterface; use indexmap::IndexMap; use std::cell::RefCell; use std::rc::Rc; -#[derive(Debug)] +/// HelperSet represents a set of helpers to be used with a command. +/// +/// @implements \IteratorAggregate<string, Helper> +#[derive(Debug, Default, Clone)] pub struct HelperSet { helpers: IndexMap<String, Rc<RefCell<dyn HelperInterface>>>, + command: Option<Rc<RefCell<dyn Command>>>, } impl HelperSet { - pub fn new(_helpers: Vec<Rc<RefCell<dyn HelperInterface>>>) -> Self { - todo!() + /// @param Helper[] $helpers An array of helper + pub fn new( + this: &Rc<RefCell<HelperSet>>, + helpers: IndexMap<HelperSetKey, Rc<RefCell<dyn HelperInterface>>>, + ) { + for (alias, helper) in helpers { + let alias = match alias { + HelperSetKey::Int(_) => None, + HelperSetKey::String(alias) => Some(alias), + }; + Self::set(this, helper, alias.as_deref()); + } } - pub fn get<T: HelperInterface + 'static>(&self, _name: &str) -> Rc<RefCell<T>> { - todo!() + pub fn set( + this: &Rc<RefCell<HelperSet>>, + helper: Rc<RefCell<dyn HelperInterface>>, + alias: Option<&str>, + ) { + let name = helper.borrow().get_name(); + this.borrow_mut().helpers.insert(name, helper.clone()); + if let Some(alias) = alias { + this.borrow_mut() + .helpers + .insert(alias.to_string(), helper.clone()); + } + + helper.borrow_mut().set_helper_set(Some(this.clone())); + } + + /// Returns true if the helper if defined. + pub fn has(&self, name: &str) -> bool { + self.helpers.contains_key(name) + } + + /// Gets a helper value. + /// + /// @throws InvalidArgumentException if the helper is not defined + pub fn get( + &self, + name: &str, + ) -> Result<Rc<RefCell<dyn HelperInterface>>, InvalidArgumentException> { + if !self.has(name) { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The helper \"%s\" is not defined.", + &[shirabe_php_shim::PhpMixed::String(name.to_string())], + ), + code: 0, + }, + )); + } + + Ok(self.helpers[name].clone()) } - pub fn set(&mut self, _helper: Rc<RefCell<dyn HelperInterface>>, _alias: Option<&str>) { - todo!() + /// @deprecated since Symfony 5.4 + pub fn set_command(&mut self, command: Option<Rc<RefCell<dyn Command>>>) { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.4", + "Method \"%s()\" is deprecated.", + "HelperSet::setCommand", + ); + + self.command = command; + } + + /// Gets the command associated with this helper set. + /// + /// @deprecated since Symfony 5.4 + pub fn get_command(&self) -> Option<Rc<RefCell<dyn Command>>> { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.4", + "Method \"%s()\" is deprecated.", + "HelperSet::getCommand", + ); + + self.command.clone() } - pub fn has(&self, _name: &str) -> bool { - todo!() + /// @return \Traversable<string, Helper> + pub fn get_iterator( + &self, + ) -> impl Iterator<Item = (&String, &Rc<RefCell<dyn HelperInterface>>)> { + self.helpers.iter() } } + +/// PHP array keys are either integers or strings; the HelperSet constructor +/// distinguishes them via `\is_int($alias)`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum HelperSetKey { + Int(i64), + String(String), +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs b/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs index 191fb6e..8aed672 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs @@ -1,17 +1,33 @@ +pub mod debug_formatter_helper; +pub mod descriptor_helper; pub mod formatter_helper; pub mod helper; pub mod helper_interface; pub mod helper_set; +pub mod process_helper; pub mod progress_bar; pub mod question_helper; +pub mod symfony_question_helper; pub mod table; +pub mod table_cell; +pub mod table_cell_style; +pub mod table_rows; pub mod table_separator; +pub mod table_style; +pub use debug_formatter_helper::*; +pub use descriptor_helper::*; pub use formatter_helper::*; pub use helper::*; pub use helper_interface::*; pub use helper_set::*; +pub use process_helper::*; pub use progress_bar::*; pub use question_helper::*; +pub use symfony_question_helper::*; pub use table::*; +pub use table_cell::*; +pub use table_cell_style::*; +pub use table_rows::*; pub use table_separator::*; +pub use table_style::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/process_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/process_helper.rs new file mode 100644 index 0000000..7652cac --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/process_helper.rs @@ -0,0 +1,306 @@ +use crate::symfony::console::helper::debug_formatter_helper::DebugFormatterHelper; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::output::console_output_interface::ConsoleOutputInterface; +use crate::symfony::console::output::output_interface::{self, OutputInterface}; +use crate::symfony::process::exception::process_failed_exception::ProcessFailedException; +use crate::symfony::process::process::Process; +use std::cell::RefCell; +use std::rc::Rc; + +/// The ProcessHelper class provides helpers to run external processes. +/// +/// @final +#[derive(Debug, Default)] +pub struct ProcessHelper { + inner: Helper, +} + +/// `$cmd` is either a `Process` instance or an array whose first element is a +/// binary path (string) or a `Process`, followed by extra environment entries. +#[derive(Debug)] +pub enum ProcessHelperCmd { + Process(Process), + Array(Vec<ProcessHelperCmdElement>), +} + +#[derive(Debug)] +pub enum ProcessHelperCmdElement { + String(String), + Process(Process), +} + +impl ProcessHelper { + /// Runs an external process. + /// + /// @param array|Process $cmd An instance of Process or an array of the command and arguments + /// @param callable|null $callback A PHP callback to run whenever there is some + /// output available on STDOUT or STDERR + pub fn run( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + cmd: ProcessHelperCmd, + error: Option<&str>, + callback: Option<Box<dyn FnMut(&str, &str)>>, + verbosity: i64, + ) -> anyhow::Result<Process> { + // `class_exists(Process::class)` guards against the optional symfony/process + // component being absent; in this port the component is always available. + + // PHP: `if ($output instanceof ConsoleOutputInterface) { $output = + // $output->getErrorOutput(); }`. Downcasting a shared `dyn + // OutputInterface` to `dyn ConsoleOutputInterface` is unresolved; + // deferred to Phase C. + let output: Rc<RefCell<dyn OutputInterface>> = + todo!("$output instanceof ConsoleOutputInterface redirect to error output"); + + let formatter: Rc<RefCell<dyn HelperInterface>> = self + .get_helper_set() + .unwrap() + .borrow() + .get("debug_formatter") + .unwrap(); + + // Normalize $cmd: a single Process becomes a one-element array. + let mut cmd = match cmd { + ProcessHelperCmd::Process(process) => { + vec![ProcessHelperCmdElement::Process(process)] + } + ProcessHelperCmd::Array(cmd) => cmd, + }; + + // `!\is_array($cmd)` cannot happen given the enum, so the TypeError branch + // is unreachable here. + + let mut process: Process; + match cmd.first() { + Some(ProcessHelperCmdElement::String(_)) => { + let command: Vec<String> = cmd + .iter() + .map(|element| match element { + ProcessHelperCmdElement::String(s) => s.clone(), + ProcessHelperCmdElement::Process(_) => unreachable!(), + }) + .collect(); + process = Process::new(command, None, None, None, Some(60.0)); + cmd = vec![]; + } + Some(ProcessHelperCmdElement::Process(_)) => { + let first = cmd.remove(0); + process = match first { + ProcessHelperCmdElement::Process(process) => process, + ProcessHelperCmdElement::String(_) => unreachable!(), + }; + } + None => { + anyhow::bail!(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Invalid command provided to \"%s()\": the command should be an array whose first element is either the path to the binary to run or a \"Process\" object.", + &[shirabe_php_shim::PhpMixed::String( + "ProcessHelper::run".to_string() + )], + ), + code: 0, + }); + } + } + + if verbosity <= output.borrow().get_verbosity() { + let started = Self::formatter_start( + &formatter, + &shirabe_php_shim::spl_object_hash_process(&process), + &self.escape_string(&process.get_command_line()), + ); + output + .borrow() + .write(&[started], false, output_interface::OUTPUT_NORMAL); + } + + let callback = if output.borrow().is_debug() { + Some(self.wrap_callback(output.clone(), &process, callback)) + } else { + callback + }; + + // PHP passes the remaining `$cmd` array as the `$env` argument to Process::run. + process.run(callback); + + if verbosity <= output.borrow().get_verbosity() { + let message = if process.is_successful() { + "Command ran successfully".to_string() + } else { + shirabe_php_shim::sprintf( + "%s Command did not run successfully", + &[match process.get_exit_code() { + Some(code) => shirabe_php_shim::PhpMixed::Int(code), + None => shirabe_php_shim::PhpMixed::Null, + }], + ) + }; + let stopped = Self::formatter_stop( + &formatter, + &shirabe_php_shim::spl_object_hash_process(&process), + &message, + process.is_successful(), + ); + output + .borrow() + .write(&[stopped], false, output_interface::OUTPUT_NORMAL); + } + + if !process.is_successful() && error.is_some() { + output.borrow().writeln( + &[shirabe_php_shim::sprintf( + "<error>%s</error>", + &[shirabe_php_shim::PhpMixed::String( + self.escape_string(error.unwrap()), + )], + )], + output_interface::OUTPUT_NORMAL, + ); + } + + Ok(process) + } + + /// Runs the process. + /// + /// This is identical to run() except that an exception is thrown if the process + /// exits with a non-zero exit code. + /// + /// @param array|Process $cmd An instance of Process or a command to run + /// @param callable|null $callback A PHP callback to run whenever there is some + /// output available on STDOUT or STDERR + /// + /// @throws ProcessFailedException + /// + /// @see run() + pub fn must_run( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + cmd: ProcessHelperCmd, + error: Option<&str>, + callback: Option<Box<dyn FnMut(&str, &str)>>, + ) -> anyhow::Result<Process> { + let process = self.run( + output, + cmd, + error, + callback, + output_interface::VERBOSITY_VERY_VERBOSE, + )?; + + if !process.is_successful() { + anyhow::bail!(ProcessFailedException::new(&process)); + } + + Ok(process) + } + + /// Wraps a Process callback to add debugging output. + pub fn wrap_callback( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + process: &Process, + mut callback: Option<Box<dyn FnMut(&str, &str)>>, + ) -> Box<dyn FnMut(&str, &str)> { + // PHP: `if ($output instanceof ConsoleOutputInterface) { $output = + // $output->getErrorOutput(); }`. Downcasting a shared `dyn + // OutputInterface` to `dyn ConsoleOutputInterface` is unresolved; + // deferred to Phase C. + let output: Rc<RefCell<dyn OutputInterface>> = + todo!("$output instanceof ConsoleOutputInterface redirect to error output"); + + let formatter: Rc<RefCell<dyn HelperInterface>> = self + .get_helper_set() + .unwrap() + .borrow() + .get("debug_formatter") + .unwrap(); + + let object_hash = shirabe_php_shim::spl_object_hash_process(process); + + Box::new(move |r#type: &str, buffer: &str| { + let progressed = Self::formatter_progress( + &formatter, + &object_hash, + &Self::escape_string_static(buffer), + Process::ERR == r#type, + ); + output + .borrow() + .write(&[progressed], false, output_interface::OUTPUT_NORMAL); + + if let Some(callback) = callback.as_mut() { + callback(r#type, buffer); + } + }) + } + + fn escape_string(&self, str: &str) -> String { + shirabe_php_shim::str_replace("<", "\\<", str) + } + + fn escape_string_static(str: &str) -> String { + shirabe_php_shim::str_replace("<", "\\<", str) + } + + /// PHP fetches `debug_formatter` from the HelperSet as a `HelperInterface` and + /// dynamically dispatches `start`/`stop`/`progress`, which are concrete + /// `DebugFormatterHelper` methods not present on the interface. Resolving the + /// dynamic helper handle back to the concrete `DebugFormatterHelper` is a + /// downcast that requires a Phase C decision (e.g. an `as_any` on + /// `HelperInterface`); deferred here. + fn debug_formatter( + _formatter: &Rc<RefCell<dyn HelperInterface>>, + ) -> Rc<RefCell<DebugFormatterHelper>> { + todo!("downcast HelperInterface handle to DebugFormatterHelper (Phase C)") + } + + fn formatter_start( + formatter: &Rc<RefCell<dyn HelperInterface>>, + id: &str, + message: &str, + ) -> String { + Self::debug_formatter(formatter) + .borrow_mut() + .start(id, message, "RUN") + } + + fn formatter_stop( + formatter: &Rc<RefCell<dyn HelperInterface>>, + id: &str, + message: &str, + successful: bool, + ) -> String { + Self::debug_formatter(formatter) + .borrow_mut() + .stop(id, message, successful, "RES") + } + + fn formatter_progress( + formatter: &Rc<RefCell<dyn HelperInterface>>, + id: &str, + buffer: &str, + error: bool, + ) -> String { + Self::debug_formatter(formatter) + .borrow_mut() + .progress(id, buffer, error, "OUT", "ERR") + } +} + +impl HelperInterface for ProcessHelper { + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "process".to_string() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs b/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs index e2a728f..bd6fe86 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs @@ -1,38 +1,826 @@ +use crate::symfony::console::cursor::Cursor; +use crate::symfony::console::exception::logic_exception::LogicException; +use crate::symfony::console::helper::helper::Helper; use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::output_interface; +use crate::symfony::console::terminal::Terminal; +use indexmap::IndexMap; +use std::cell::RefCell; +use std::rc::Rc; +pub const FORMAT_VERBOSE: &str = "verbose"; +pub const FORMAT_VERY_VERBOSE: &str = "very_verbose"; +pub const FORMAT_DEBUG: &str = "debug"; +pub const FORMAT_NORMAL: &str = "normal"; + +const FORMAT_VERBOSE_NOMAX: &str = "verbose_nomax"; +const FORMAT_VERY_VERBOSE_NOMAX: &str = "very_verbose_nomax"; +const FORMAT_DEBUG_NOMAX: &str = "debug_nomax"; +const FORMAT_NORMAL_NOMAX: &str = "normal_nomax"; + +/// A placeholder formatter callable, receiving the bar and the output. +pub type PlaceholderFormatter = Box< + dyn Fn( + &ProgressBar, + &Rc<RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<Result<shirabe_php_shim::PhpMixed, LogicException>>, +>; + +/// The ProgressBar provides helpers to display progress output. #[derive(Debug)] -pub struct ProgressBar; +pub struct ProgressBar { + bar_width: i64, + bar_char: Option<String>, + empty_bar_char: String, + progress_char: String, + format: Option<String>, + internal_format: Option<String>, + redraw_freq: Option<i64>, + write_count: i64, + last_write_time: f64, + min_seconds_between_redraws: f64, + max_seconds_between_redraws: f64, + output: Rc<RefCell<dyn OutputInterface>>, + step: i64, + max: i64, + start_time: i64, + step_width: i64, + percent: f64, + messages: IndexMap<String, String>, + overwrite: bool, + terminal: Terminal, + previous_message: Option<String>, + cursor: Cursor, +} + +thread_local! { + static FORMATTERS: RefCell<Option<IndexMap<String, PlaceholderFormatter>>> = const { RefCell::new(None) }; + static FORMATS: RefCell<Option<IndexMap<String, String>>> = const { RefCell::new(None) }; +} impl ProgressBar { - pub fn new(_output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, _max: i64) -> Self { - todo!() + /// `$max` Maximum steps (0 if unknown) + pub fn new( + output: Rc<RefCell<dyn OutputInterface>>, + max: i64, + min_seconds_between_redraws: f64, + ) -> Self { + let output = if todo!("$output instanceof ConsoleOutputInterface") { + todo!("$output = $output->getErrorOutput();") + } else { + output + }; + + let mut this = Self { + bar_width: 28, + bar_char: None, + empty_bar_char: "-".to_string(), + progress_char: ">".to_string(), + format: None, + internal_format: None, + redraw_freq: Some(1), + write_count: 0, + last_write_time: 0.0, + min_seconds_between_redraws: 0.0, + max_seconds_between_redraws: 1.0, + output: output.clone(), + step: 0, + max: 0, + start_time: 0, + step_width: 0, + percent: 0.0, + messages: IndexMap::new(), + overwrite: true, + terminal: Terminal::new(), + previous_message: None, + cursor: Cursor::new(output.clone(), None), + }; + + this.set_max_steps(max); + + if 0.0 < min_seconds_between_redraws { + this.redraw_freq = None; + this.min_seconds_between_redraws = min_seconds_between_redraws; + } + + if !this.output.borrow().is_decorated() { + // disable overwrite when output does not support ANSI codes. + this.overwrite = false; + + // set a reasonable redraw frequency so output isn't flooded + this.redraw_freq = None; + } + + this.start_time = shirabe_php_shim::time(); + + this } - pub fn start(&mut self, _max: Option<i64>) { - todo!() + /// Sets a placeholder formatter for a given name. + /// + /// This method also allow you to override an existing placeholder. + /// + /// `$name` The placeholder name (including the delimiter char like %) + /// `$callable` A PHP callable + pub fn set_placeholder_formatter_definition(name: &str, callable: PlaceholderFormatter) { + FORMATTERS.with(|formatters| { + let mut formatters = formatters.borrow_mut(); + if formatters.is_none() { + *formatters = Some(Self::init_placeholder_formatters()); + } + + formatters + .as_mut() + .unwrap() + .insert(name.to_string(), callable); + }); } - pub fn advance(&mut self, _step: i64) { - todo!() + /// Gets the placeholder formatter for a given name. + /// + /// `$name` The placeholder name (including the delimiter char like %) + pub fn get_placeholder_formatter_definition(name: &str) -> Option<()> { + // Note: the returned callable cannot be cloned out of the thread-local + // map; call sites invoke the formatter via the map directly. + FORMATTERS.with(|formatters| { + let mut formatters = formatters.borrow_mut(); + if formatters.is_none() { + *formatters = Some(Self::init_placeholder_formatters()); + } + + formatters.as_ref().unwrap().get(name).map(|_| ()) + }) } - pub fn finish(&mut self) { - todo!() + /// Sets a format for a given name. + /// + /// This method also allow you to override an existing format. + /// + /// `$name` The format name + /// `$format` A format string + pub fn set_format_definition(name: &str, format: &str) { + FORMATS.with(|formats| { + let mut formats = formats.borrow_mut(); + if formats.is_none() { + *formats = Some(Self::init_formats()); + } + + formats + .as_mut() + .unwrap() + .insert(name.to_string(), format.to_string()); + }); } - pub fn set_format(&mut self, _format: &str) { - todo!() + /// Gets the format for a given name. + /// + /// `$name` The format name + pub fn get_format_definition(name: &str) -> Option<String> { + FORMATS.with(|formats| { + let mut formats = formats.borrow_mut(); + if formats.is_none() { + *formats = Some(Self::init_formats()); + } + + formats.as_ref().unwrap().get(name).cloned() + }) } - pub fn get_progress(&self) -> i64 { - todo!() + /// Associates a text with a named placeholder. + /// + /// The text is displayed when the progress bar is rendered but only + /// when the corresponding placeholder is part of the custom format line + /// (by wrapping the name with %). + /// + /// `$message` The text to associate with the placeholder + /// `$name` The name of the placeholder + pub fn set_message(&mut self, message: &str, name: &str) { + self.messages.insert(name.to_string(), message.to_string()); + } + + pub fn get_message(&self, name: &str) -> Option<String> { + self.messages.get(name).cloned() + } + + pub fn get_start_time(&self) -> i64 { + self.start_time } pub fn get_max_steps(&self) -> i64 { - todo!() + self.max + } + + pub fn get_progress(&self) -> i64 { + self.step + } + + fn get_step_width(&self) -> i64 { + self.step_width + } + + pub fn get_progress_percent(&self) -> f64 { + self.percent + } + + pub fn get_bar_offset(&self) -> f64 { + shirabe_php_shim::floor(if self.max != 0 { + self.percent * self.bar_width as f64 + } else if self.redraw_freq.is_none() { + ((shirabe_php_shim::min(5, self.bar_width / 15) * self.write_count) % self.bar_width) + as f64 + } else { + (self.step % self.bar_width) as f64 + }) + } + + pub fn get_estimated(&self) -> f64 { + if self.step == 0 { + return 0.0; + } + + shirabe_php_shim::round( + (shirabe_php_shim::time() - self.start_time) as f64 / self.step as f64 + * self.max as f64, + 0, + ) + } + + pub fn get_remaining(&self) -> f64 { + if self.step == 0 { + return 0.0; + } + + shirabe_php_shim::round( + (shirabe_php_shim::time() - self.start_time) as f64 / self.step as f64 + * (self.max - self.step) as f64, + 0, + ) + } + + pub fn set_bar_width(&mut self, size: i64) { + self.bar_width = shirabe_php_shim::max(1, size); + } + + pub fn get_bar_width(&self) -> i64 { + self.bar_width + } + + pub fn set_bar_character(&mut self, char: &str) { + self.bar_char = Some(char.to_string()); + } + + pub fn get_bar_character(&self) -> String { + match &self.bar_char { + Some(bar_char) => bar_char.clone(), + None => { + if self.max != 0 { + "=".to_string() + } else { + self.empty_bar_char.clone() + } + } + } + } + + pub fn set_empty_bar_character(&mut self, char: &str) { + self.empty_bar_char = char.to_string(); + } + + pub fn get_empty_bar_character(&self) -> String { + self.empty_bar_char.clone() + } + + pub fn set_progress_character(&mut self, char: &str) { + self.progress_char = char.to_string(); + } + + pub fn get_progress_character(&self) -> String { + self.progress_char.clone() + } + + pub fn set_format(&mut self, format: &str) { + self.format = None; + self.internal_format = Some(format.to_string()); + } + + /// Sets the redraw frequency. + /// + /// `$freq` The frequency in steps + pub fn set_redraw_frequency(&mut self, freq: Option<i64>) { + self.redraw_freq = freq.map(|freq| shirabe_php_shim::max(1, freq)); + } + + pub fn min_seconds_between_redraws(&mut self, seconds: f64) { + self.min_seconds_between_redraws = seconds; + } + + pub fn max_seconds_between_redraws(&mut self, seconds: f64) { + self.max_seconds_between_redraws = seconds; + } + + /// Returns an iterator that will automatically update the progress bar when iterated. + /// + /// `$max` Number of steps to complete the bar (0 if indeterminate), if null it will be + /// inferred from `$iterable` + pub fn iterate( + &mut self, + iterable: Vec<(shirabe_php_shim::PhpMixed, shirabe_php_shim::PhpMixed)>, + max: Option<i64>, + ) -> anyhow::Result<Vec<(shirabe_php_shim::PhpMixed, shirabe_php_shim::PhpMixed)>> { + self.start(Some(max.unwrap_or_else(|| { + // is_countable($iterable) ? \count($iterable) : 0 + iterable.len() as i64 + })))?; + + let mut yielded = Vec::new(); + for (key, value) in iterable { + yielded.push((key, value)); + + self.advance(1)?; + } + + self.finish()?; + + Ok(yielded) + } + + /// Starts the progress output. + /// + /// `$max` Number of steps to complete the bar (0 if indeterminate), null to leave unchanged + pub fn start(&mut self, max: Option<i64>) -> anyhow::Result<()> { + self.start_time = shirabe_php_shim::time(); + self.step = 0; + self.percent = 0.0; + + if let Some(max) = max { + self.set_max_steps(max); + } + + self.display() + } + + /// Advances the progress output X steps. + /// + /// `$step` Number of steps to advance + pub fn advance(&mut self, step: i64) -> anyhow::Result<()> { + self.set_progress(self.step + step) + } + + /// Sets whether to overwrite the progressbar, false for new line. + pub fn set_overwrite(&mut self, overwrite: bool) { + self.overwrite = overwrite; + } + + pub fn set_progress(&mut self, mut step: i64) -> anyhow::Result<()> { + if self.max != 0 && step > self.max { + self.max = step; + } else if step < 0 { + step = 0; + } + + let redraw_freq = match self.redraw_freq { + Some(redraw_freq) => redraw_freq as f64, + None => (if self.max != 0 { self.max } else { 10 }) as f64 / 10.0, + }; + let prev_period = (self.step as f64 / redraw_freq) as i64; + let curr_period = (step as f64 / redraw_freq) as i64; + self.step = step; + self.percent = if self.max != 0 { + self.step as f64 / self.max as f64 + } else { + 0.0 + }; + let time_interval = shirabe_php_shim::microtime(true) - self.last_write_time; + + // Draw regardless of other limits + if self.max == step { + self.display()?; + + return Ok(()); + } + + // Throttling + if time_interval < self.min_seconds_between_redraws { + return Ok(()); + } + + // Draw each step period, but not too late + if prev_period != curr_period || time_interval >= self.max_seconds_between_redraws { + self.display()?; + } + + Ok(()) + } + + pub fn set_max_steps(&mut self, max: i64) { + self.format = None; + self.max = shirabe_php_shim::max(0, max); + self.step_width = if self.max != 0 { + Helper::width(&self.max.to_string()) + } else { + 4 + }; + } + + /// Finishes the progress output. + pub fn finish(&mut self) -> anyhow::Result<()> { + if self.max == 0 { + self.max = self.step; + } + + if self.step == self.max && !self.overwrite { + // prevent double 100% output + return Ok(()); + } + + self.set_progress(self.max) + } + + /// Outputs the current progress string. + pub fn display(&mut self) -> anyhow::Result<()> { + if output_interface::VERBOSITY_QUIET == self.output.borrow().get_verbosity() { + return Ok(()); + } + + if self.format.is_none() { + let format = match &self.internal_format { + Some(internal_format) if !internal_format.is_empty() => internal_format.clone(), + _ => self.determine_best_format().to_string(), + }; + self.set_real_format(&format); + } + + let line = self.build_line()?; + self.overwrite(&line); + + Ok(()) + } + + /// Removes the progress bar from the current line. + /// + /// This is useful if you wish to write some output + /// while a progress bar is running. + /// Call display() to show the progress bar again. + pub fn clear(&mut self) -> anyhow::Result<()> { + if !self.overwrite { + return Ok(()); + } + + if self.format.is_none() { + let format = match &self.internal_format { + Some(internal_format) if !internal_format.is_empty() => internal_format.clone(), + _ => self.determine_best_format().to_string(), + }; + self.set_real_format(&format); + } + + self.overwrite(""); + + Ok(()) + } + + fn set_real_format(&mut self, format: &str) { + // try to use the _nomax variant if available + if self.max == 0 && Self::get_format_definition(&format!("{format}_nomax")).is_some() { + self.format = Self::get_format_definition(&format!("{format}_nomax")); + } else if Self::get_format_definition(format).is_some() { + self.format = Self::get_format_definition(format); + } else { + self.format = Some(format.to_string()); + } + } + + /// Overwrites a previous message to the output. + fn overwrite(&mut self, message: &str) { + if self.previous_message.as_deref() == Some(message) { + return; + } + + let original_message = message.to_string(); + let mut message = message.to_string(); + + if self.overwrite { + if let Some(previous_message) = self.previous_message.clone() { + if todo!("$this->output instanceof ConsoleSectionOutput") { + let message_lines = shirabe_php_shim::explode("\n", &previous_message); + let mut line_count = message_lines.len() as i64; + for message_line in &message_lines { + let message_line_length = Helper::width(&Helper::remove_decoration( + todo!("$this->output->getFormatter()"), + message_line, + )); + if message_line_length > self.terminal.get_width() { + line_count += shirabe_php_shim::floor( + message_line_length as f64 / self.terminal.get_width() as f64, + ) as i64; + } + } + todo!("$this->output->clear($lineCount); (ConsoleSectionOutput)"); + } else { + let line_count = shirabe_php_shim::substr_count(&previous_message, "\n"); + for _i in 0..line_count { + self.cursor.move_to_column(1); + self.cursor.clear_line(); + self.cursor.move_up(1); + } + + self.cursor.move_to_column(1); + self.cursor.clear_line(); + } + } + } else if self.step > 0 { + message = format!("{}{}", shirabe_php_shim::PHP_EOL, message); + } + + self.previous_message = Some(original_message); + self.last_write_time = shirabe_php_shim::microtime(true); + + self.output + .borrow() + .write(&[message], false, output_interface::OUTPUT_NORMAL); + self.write_count += 1; } - pub fn set_progress(&mut self, _step: i64) { - todo!() + fn determine_best_format(&self) -> &'static str { + match self.output.borrow().get_verbosity() { + // OutputInterface::VERBOSITY_QUIET: display is disabled anyway + output_interface::VERBOSITY_VERBOSE => { + if self.max != 0 { + FORMAT_VERBOSE + } else { + FORMAT_VERBOSE_NOMAX + } + } + output_interface::VERBOSITY_VERY_VERBOSE => { + if self.max != 0 { + FORMAT_VERY_VERBOSE + } else { + FORMAT_VERY_VERBOSE_NOMAX + } + } + output_interface::VERBOSITY_DEBUG => { + if self.max != 0 { + FORMAT_DEBUG + } else { + FORMAT_DEBUG_NOMAX + } + } + _ => { + if self.max != 0 { + FORMAT_NORMAL + } else { + FORMAT_NORMAL_NOMAX + } + } + } + } + + fn init_placeholder_formatters() -> IndexMap<String, PlaceholderFormatter> { + let mut formatters: IndexMap<String, PlaceholderFormatter> = IndexMap::new(); + + formatters.insert( + "bar".to_string(), + Box::new( + |bar: &ProgressBar, output: &Rc<RefCell<dyn OutputInterface>>| { + let complete_bars = bar.get_bar_offset(); + let mut display = shirabe_php_shim::str_repeat( + &bar.get_bar_character(), + complete_bars as usize, + ); + if complete_bars < bar.get_bar_width() as f64 { + let empty_bars = bar.get_bar_width() as f64 + - complete_bars + - Helper::length(&Helper::remove_decoration( + todo!("$output->getFormatter()"), + &bar.get_progress_character(), + )) as f64; + display.push_str(&format!( + "{}{}", + bar.get_progress_character(), + shirabe_php_shim::str_repeat( + &bar.get_empty_bar_character(), + empty_bars as usize + ) + )); + let _ = output; + } + + Ok(Ok(shirabe_php_shim::PhpMixed::String(display))) + }, + ), + ); + + formatters.insert( + "elapsed".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_time( + (shirabe_php_shim::time() - bar.get_start_time()) as f64, + ) + .unwrap_or_default(), + ))) + }, + ), + ); + + formatters.insert( + "remaining".to_string(), + Box::new(|bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + if bar.get_max_steps() == 0 { + return Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: "Unable to display the remaining time if the maximum number of steps is not set.".to_string(), + code: 0, + }))); + } + + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_time(bar.get_remaining()).unwrap_or_default(), + ))) + }), + ); + + formatters.insert( + "estimated".to_string(), + Box::new(|bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + if bar.get_max_steps() == 0 { + return Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: "Unable to display the estimated time if the maximum number of steps is not set.".to_string(), + code: 0, + }))); + } + + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_time(bar.get_estimated()).unwrap_or_default(), + ))) + }), + ); + + formatters.insert( + "memory".to_string(), + Box::new( + |_bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_memory(shirabe_php_shim::memory_get_usage()), + ))) + }, + ), + ); + + formatters.insert( + "current".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::String( + shirabe_php_shim::str_pad( + &bar.get_progress().to_string(), + bar.get_step_width() as usize, + " ", + shirabe_php_shim::STR_PAD_LEFT, + ), + ))) + }, + ), + ); + + formatters.insert( + "max".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::Int(bar.get_max_steps()))) + }, + ), + ); + + formatters.insert( + "percent".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::Float( + shirabe_php_shim::floor(bar.get_progress_percent() * 100.0), + ))) + }, + ), + ); + + formatters + } + + fn init_formats() -> IndexMap<String, String> { + let mut formats: IndexMap<String, String> = IndexMap::new(); + + formats.insert( + FORMAT_NORMAL.to_string(), + " %current%/%max% [%bar%] %percent:3s%%".to_string(), + ); + formats.insert( + FORMAT_NORMAL_NOMAX.to_string(), + " %current% [%bar%]".to_string(), + ); + + formats.insert( + FORMAT_VERBOSE.to_string(), + " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%".to_string(), + ); + formats.insert( + FORMAT_VERBOSE_NOMAX.to_string(), + " %current% [%bar%] %elapsed:6s%".to_string(), + ); + + formats.insert( + FORMAT_VERY_VERBOSE.to_string(), + " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%".to_string(), + ); + formats.insert( + FORMAT_VERY_VERBOSE_NOMAX.to_string(), + " %current% [%bar%] %elapsed:6s%".to_string(), + ); + + formats.insert( + FORMAT_DEBUG.to_string(), + " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%" + .to_string(), + ); + formats.insert( + FORMAT_DEBUG_NOMAX.to_string(), + " %current% [%bar%] %elapsed:6s% %memory:6s%".to_string(), + ); + + formats + } + + fn build_line(&mut self) -> anyhow::Result<String> { + let regex = "{%([a-z\\-_]+)(?:\\:([^%]+))?%}i"; + + // The callback resolves a placeholder match into its replacement text. + // It is invoked by preg_replace_callback over $this->format. + let line = self.build_line_apply(regex)?; + + // gets string length for each sub line with multiline format + let lines_length: Vec<i64> = shirabe_php_shim::explode("\n", &line) + .iter() + .map(|sub_line| { + Helper::width(&Helper::remove_decoration( + todo!("$this->output->getFormatter()"), + &shirabe_php_shim::rtrim(sub_line, Some("\r")), + )) + }) + .collect(); + + let lines_width = *lines_length.iter().max().unwrap(); + + let terminal_width = self.terminal.get_width(); + if lines_width <= terminal_width { + return Ok(line); + } + + self.set_bar_width(self.bar_width - lines_width + terminal_width); + + self.build_line_apply(regex) + } + + /// Applies the placeholder-resolving callback over `$this->format`, mirroring + /// the `preg_replace_callback` invocation in PHP's `buildLine()`. + fn build_line_apply(&self, regex: &str) -> anyhow::Result<String> { + let format = self.format.clone().unwrap_or_default(); + + // $callback in PHP, expressed as a closure over $this and the matches. + let callback = |matches: &[Option<String>]| -> anyhow::Result<String> { + let name = matches[1].clone().unwrap_or_default(); + + let text: shirabe_php_shim::PhpMixed = + if Self::get_placeholder_formatter_definition(&name).is_some() { + // $text = $formatter($this, $this->output); + let formatter_result = FORMATTERS.with(|formatters| { + let formatters = formatters.borrow(); + let formatter = formatters.as_ref().unwrap().get(&name).unwrap(); + formatter(self, &self.output) + }); + match formatter_result? { + Ok(text) => text, + Err(e) => return Err(anyhow::Error::new(e)), + } + } else if let Some(message) = self.messages.get(&name) { + shirabe_php_shim::PhpMixed::String(message.clone()) + } else { + return Ok(matches[0].clone().unwrap_or_default()); + }; + + if let Some(modifier) = matches.get(2).and_then(|m| m.clone()) { + return Ok(shirabe_php_shim::sprintf(&format!("%{modifier}"), &[text])); + } + + // PHP implicitly casts the formatter result to string here. + Ok(match text { + shirabe_php_shim::PhpMixed::String(s) => s, + shirabe_php_shim::PhpMixed::Int(i) => i.to_string(), + shirabe_php_shim::PhpMixed::Float(f) => { + shirabe_php_shim::sprintf("%s", &[shirabe_php_shim::PhpMixed::Float(f)]) + } + other => shirabe_php_shim::sprintf("%s", &[other]), + }) + }; + + shirabe_php_shim::preg_replace_callback(regex, callback, &format) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs index c17cb6b..7ddb620 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs @@ -1,25 +1,933 @@ -use crate::symfony::console::helper::HelperInterface; -use crate::symfony::console::input::InputInterface; -use crate::symfony::console::output::OutputInterface; -use crate::symfony::console::question::Question; +use crate::symfony::console::cursor::Cursor; +use crate::symfony::console::exception::missing_input_exception::MissingInputException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::formatter::output_formatter_style::OutputFormatterStyle; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::console_output::ConsoleOutput; +use crate::symfony::console::output::console_output_interface::ConsoleOutputInterface; +use crate::symfony::console::output::console_section_output::ConsoleSectionOutput; +use crate::symfony::console::output::output_interface; +use crate::symfony::console::output::output_interface::OutputInterface; +use crate::symfony::console::question::choice_question::ChoiceQuestion; +use crate::symfony::console::question::question::Question; +use crate::symfony::console::terminal::Terminal; +use crate::symfony::string::s; +use shirabe_php_shim::AsAny; use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; -#[derive(Debug)] -pub struct QuestionHelper; +/// The QuestionHelper class provides helpers to interact with the user. +#[derive(Debug, Default)] +pub struct QuestionHelper { + pub(crate) inner: Helper, + + /// @var resource|null + input_stream: Option<shirabe_php_shim::PhpResource>, +} + +/// self::$stty +static STTY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); +/// self::$stdinIsInteractive +static STDIN_IS_INTERACTIVE: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None); impl QuestionHelper { + /// Asks a question to the user. + /// + /// @return mixed The user answer + /// + /// @throws RuntimeException If there is no data to read in the input stream pub fn ask( + &mut self, + input: &mut dyn InputInterface, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) -> anyhow::Result<Result<PhpMixed, MissingInputException>> { + let mut output = output; + let error_output = { + let borrowed = output.borrow(); + (*borrowed) + .as_any() + .downcast_ref::<ConsoleOutput>() + .map(|console_output| console_output.get_error_output()) + }; + if let Some(error_output) = error_output { + output = error_output; + } + + if !input.is_interactive() { + return Ok(Ok(self.get_default_answer(question))); + } + + // TODO(phase-b): `$input instanceof StreamableInputInterface` cannot be + // expressed as a trait-object-to-trait-object downcast, and no concrete + // streamable input type is wired up yet. The stream is left unset so the + // helper falls back to STDIN. Revisit once StreamableInputInterface has a + // concrete implementor and the PhpResource/PhpMixed split is resolved. + let _ = &mut self.input_stream; + + let result: anyhow::Result<Result<PhpMixed, MissingInputException>> = (|| { + if question.get_validator().is_none() { + return self.do_ask(Rc::clone(&output), question); + } + + let interviewer = || self.do_ask(Rc::clone(&output), question); + + self.validate_attempts(&interviewer, Rc::clone(&output), question) + })(); + + let result = result?; + match result { + Ok(value) => Ok(Ok(value)), + Err(exception) => { + input.set_interactive(false); + + let fallback_output = self.get_default_answer(question); + if matches!(fallback_output, PhpMixed::Null) { + return Ok(Err(exception)); + } + + Ok(Ok(fallback_output)) + } + } + } + + pub fn get_name(&self) -> String { + "question".to_string() + } + + /// Prevents usage of stty. + pub fn disable_stty() { + STTY.store(false, std::sync::atomic::Ordering::SeqCst); + } + + /// Asks the question to the user. + /// + /// @return mixed + /// + /// @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden + fn do_ask( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) -> anyhow::Result<Result<PhpMixed, MissingInputException>> { + self.write_prompt(Rc::clone(&output), question); + + let input_stream = self + .input_stream + .clone() + .unwrap_or_else(shirabe_php_shim::stdin); + let autocomplete = question.get_autocompleter_callback(); + + let ret: PhpMixed; + if autocomplete.is_none() + || !STTY.load(std::sync::atomic::Ordering::SeqCst) + || !Terminal::has_stty_available() + { + let mut r: PhpMixed = PhpMixed::Bool(false); + if question.is_hidden() { + match self.get_hidden_response( + Rc::clone(&output), + &input_stream, + question.is_trimmable(), + )? { + Ok(hidden_response) => { + r = PhpMixed::String(if question.is_trimmable() { + shirabe_php_shim::trim(&hidden_response, None) + } else { + hidden_response + }); + } + Err(e) => { + if !question.is_hidden_fallback() { + return Err(e.into()); + } + } + } + } + + if matches!(r, PhpMixed::Bool(false)) { + let is_blocked = shirabe_php_shim::stream_get_meta_data(&input_stream) + .get("blocked") + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Bool(true)); + + if !shirabe_php_shim::boolval(&is_blocked) { + shirabe_php_shim::stream_set_blocking(&input_stream, true); + } + + let read = self.read_input(&input_stream, question); + + if !shirabe_php_shim::boolval(&is_blocked) { + shirabe_php_shim::stream_set_blocking(&input_stream, false); + } + + if matches!(read, PhpMixed::Bool(false)) { + return Ok(Err(MissingInputException(RuntimeException( + shirabe_php_shim::RuntimeException { + message: "Aborted.".to_string(), + code: 0, + }, + )))); + } + r = read; + if question.is_trimmable() { + r = PhpMixed::String(shirabe_php_shim::trim(&r.to_string(), None)); + } + } + ret = r; + } else { + let callback = autocomplete.unwrap(); + // The autocompleter callback yields an iterable (Option here); PHP + // treats a null result as an empty list of suggestions. + let callback = move |input: &str| callback(input).unwrap_or_default(); + let autocomplete = + self.autocomplete(Rc::clone(&output), question, &input_stream, &callback); + ret = PhpMixed::String(if question.is_trimmable() { + shirabe_php_shim::trim(&autocomplete, None) + } else { + autocomplete + }); + } + + let mut ret = ret; + { + let borrowed = output.borrow(); + if let Some(section_output) = + (*borrowed).as_any().downcast_ref::<ConsoleSectionOutput>() + { + section_output.add_content(&ret.to_string()); + } + } + + ret = if shirabe_php_shim::strlen(&ret.to_string()) > 0 { + ret + } else { + question.get_default() + }; + + if let Some(normalizer) = question.get_normalizer() { + return Ok(Ok(normalizer(ret))); + } + + Ok(Ok(ret)) + } + + /// @return mixed + fn get_default_answer(&self, question: &Question) -> PhpMixed { + let default = question.get_default(); + + if matches!(default, PhpMixed::Null) { + return default; + } + + if let Some(validator) = question.get_validator() { + // call_user_func($question->getValidator(), $default) + return validator(Some(default)).unwrap(); + } else if let Some(choice_question) = question_as_choice_question(question) { + let choices = choice_question.get_choices(); + + if !choice_question.is_multiselect() { + return choices + .get(&default.to_string()) + .map(|v| (**v).clone()) + .unwrap_or(default); + } + + let default_parts = shirabe_php_shim::explode(",", &default.to_string()); + let mut resolved: indexmap::IndexMap<String, Box<PhpMixed>> = indexmap::IndexMap::new(); + for (k, v) in default_parts.iter().enumerate() { + let v = if question.is_trimmable() { + shirabe_php_shim::trim(v, None) + } else { + v.clone() + }; + let value = choices + .get(&v) + .map(|value| (**value).clone()) + .unwrap_or(PhpMixed::String(v)); + resolved.insert(k.to_string(), Box::new(value)); + } + + return PhpMixed::Array(resolved); + } + + default + } + + /// Outputs the question prompt. + pub(crate) fn write_prompt( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) { + let mut message = question.get_question().to_string(); + + if let Some(choice_question) = question_as_choice_question(question) { + let mut lines = vec![question.get_question().to_string()]; + lines.extend(self.format_choice_question_choices(choice_question, "info")); + output + .borrow() + .writeln(&lines, output_interface::OUTPUT_NORMAL); + + message = choice_question.get_prompt().to_string(); + } + + output + .borrow() + .write(&[message], false, output_interface::OUTPUT_NORMAL); + } + + /// @return string[] + pub(crate) fn format_choice_question_choices( + &self, + question: &ChoiceQuestion, + tag: &str, + ) -> Vec<String> { + let mut messages: Vec<String> = vec![]; + + let choices = question.get_choices(); + let max_width = choices + .keys() + .map(|key| Helper::width(key)) + .max() + .unwrap_or(0); + + for (key, value) in choices { + let padding = + shirabe_php_shim::str_repeat(" ", (max_width - Helper::width(key)) as usize); + + messages.push(shirabe_php_shim::sprintf( + &format!(" [<{tag}>%s{padding}</{tag}>] %s"), + &[PhpMixed::String(key.clone()), (**value).clone()], + )); + } + + messages + } + + /// Outputs an error message. + pub(crate) fn write_error( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + error: &shirabe_php_shim::Exception, + ) { + let message; + if let Some(helper_set) = self.get_helper_set() { + if helper_set.borrow().has("formatter") { + // PHP: `$this->getHelperSet()->get('formatter')->formatBlock(...)`. + // HelperSet::get yields `dyn HelperInterface`, which does not have + // `AsAny` as a supertrait, so it cannot be downcast to + // FormatterHelper. Resolved once HelperInterface gains AsAny (see + // report). + let _formatter = helper_set.borrow().get("formatter").unwrap(); + todo!("downcast dyn HelperInterface to FormatterHelper for format_block"); + } else { + message = format!("<error>{}</error>", error.message); + } + } else { + message = format!("<error>{}</error>", error.message); + } + + output + .borrow() + .writeln(&[message], output_interface::OUTPUT_NORMAL); + } + + /// Autocompletes a question. + /// + /// @param resource $inputStream + fn autocomplete( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + input_stream: &shirabe_php_shim::PhpResource, + autocomplete: &dyn Fn(&str) -> Vec<PhpMixed>, + ) -> String { + // TODO(phase-b): Cursor takes Option<PhpMixed>, but the input stream is a + // PhpResource and PhpMixed has no resource variant. Defaulting to STDIN + // until the PhpResource/PhpMixed stream representation is unified. + let cursor = Cursor::new(Rc::clone(&output), None); + + let mut full_choice = String::new(); + let mut ret = String::new(); + + let mut i: i64 = 0; + let mut ofs: i64 = -1; + let mut matches = autocomplete(&ret); + let mut num_matches = matches.len() as i64; + + let stty_mode = shirabe_php_shim::shell_exec("stty -g").unwrap_or_default(); + let is_stdin = shirabe_php_shim::stream_get_meta_data(input_stream) + .get("uri") + .map(|uri| uri.to_string() == "php://stdin") + .unwrap_or(false); + let mut r = vec![input_stream.clone()]; + let w: Vec<shirabe_php_shim::PhpResource> = vec![]; + + // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead) + shirabe_php_shim::shell_exec("stty -icanon -echo"); + + // Add highlighted text style + output.borrow().get_formatter().borrow_mut().set_style( + "hl", + Box::new(OutputFormatterStyle::new( + Some("black"), + Some("white"), + vec![], + )), + ); + + // Read a keypress + while !feof_resource(input_stream) { + while is_stdin + && 0 == shirabe_php_shim::stream_select( + &mut r, + &mut w.clone(), + &mut w.clone(), + 0, + Some(100), + ) + { + // Give signal handlers a chance to run + r = vec![input_stream.clone()]; + } + let mut c = fread_resource(input_stream, 1); + + // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false. + if c.is_none() + || (ret.is_empty() + && c.as_deref() == Some("") + && matches!(question.get_default(), PhpMixed::Null)) + { + shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode)); + // throw new MissingInputException('Aborted.'); + // autocomplete() returns string in PHP; this throw aborts the + // whole read. Faithful exception propagation is resolved later. + todo!("MissingInputException('Aborted.') thrown inside autocomplete"); + } else if c.as_deref() == Some("\u{7f}") { + // Backspace Character + if 0 == num_matches && 0 != i { + i -= 1; + cursor.move_left(s(&full_choice).slice(-1, None).width(false)); + + full_choice = QuestionHelper::substr(Some(&full_choice), 0, Some(i)); + } + + if 0 == i { + ofs = -1; + matches = autocomplete(&ret); + num_matches = matches.len() as i64; + } else { + num_matches = 0; + } + + // Pop the last character off the end of our string + ret = QuestionHelper::substr(Some(&ret), 0, Some(i)); + } else if c.as_deref() == Some("\u{1b}") { + // Did we read an escape sequence? + let escape = fread_resource(input_stream, 2).unwrap_or_default(); + let cc = format!("{}{}", c.clone().unwrap_or_default(), escape); + c = Some(cc.clone()); + + // A = Up Arrow. B = Down Arrow + let c2 = cc.as_bytes().get(2).copied(); + if c2 == Some(b'A') || c2 == Some(b'B') { + if c2 == Some(b'A') && -1 == ofs { + ofs = 0; + } + + if 0 == num_matches { + continue; + } + + ofs += if c2 == Some(b'A') { -1 } else { 1 }; + ofs = (num_matches + ofs) % num_matches; + } + } else if shirabe_php_shim::ord(c.as_deref().unwrap_or("")) < 32 { + if c.as_deref() == Some("\t") || c.as_deref() == Some("\n") { + if num_matches > 0 && -1 != ofs { + ret = matches[ofs as usize].to_string(); + // Echo out remaining chars for current match + let remaining_characters = shirabe_php_shim::substr( + &ret, + shirabe_php_shim::strlen(&shirabe_php_shim::trim( + &self.most_recently_entered_value(&full_choice), + None, + )), + None, + ); + output.borrow().write( + &[remaining_characters.clone()], + false, + output_interface::OUTPUT_NORMAL, + ); + full_choice.push_str(&remaining_characters); + i = match shirabe_php_shim::mb_detect_encoding(&full_choice, None, true) { + None => shirabe_php_shim::strlen(&full_choice), + Some(encoding) => shirabe_php_shim::mb_strlen(&full_choice, &encoding), + }; + + let ret_for_filter = ret.clone(); + matches = autocomplete(&ret) + .into_iter() + .filter(|m| { + ret_for_filter.is_empty() + || shirabe_php_shim::str_starts_with( + &m.to_string(), + &ret_for_filter, + ) + }) + .collect(); + num_matches = matches.len() as i64; + ofs = -1; + } + + if c.as_deref() == Some("\n") { + output.borrow().write( + &[c.clone().unwrap_or_default()], + false, + output_interface::OUTPUT_NORMAL, + ); + break; + } + + num_matches = 0; + } + + continue; + } else { + let cur = c.clone().unwrap_or_default(); + if "\u{80}" <= cur.as_str() { + let len = match shirabe_php_shim::str_bitand(&cur, "\u{f0}").as_str() { + "\u{c0}" => 1, + "\u{d0}" => 1, + "\u{e0}" => 2, + "\u{f0}" => 3, + _ => 0, + }; + let extra = fread_resource(input_stream, len).unwrap_or_default(); + c = Some(format!("{}{}", cur, extra)); + } + + let cur = c.clone().unwrap_or_default(); + output + .borrow() + .write(&[cur.clone()], false, output_interface::OUTPUT_NORMAL); + ret.push_str(&cur); + full_choice.push_str(&cur); + i += 1; + + let mut temp_ret = ret.clone(); + + if let Some(choice_question) = question_as_choice_question(question) { + if choice_question.is_multiselect() { + temp_ret = self.most_recently_entered_value(&full_choice); + } + } + + num_matches = 0; + ofs = 0; + + for value in autocomplete(&ret) { + // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle) + if shirabe_php_shim::str_starts_with(&value.to_string(), &temp_ret) { + if (num_matches as usize) < matches.len() { + matches[num_matches as usize] = value; + } else { + matches.push(value); + } + num_matches += 1; + } + } + } + + cursor.clear_line_after(); + + if num_matches > 0 && -1 != ofs { + cursor.save_position(); + // Write highlighted text, complete the partially entered response + let characters_entered = shirabe_php_shim::strlen(&shirabe_php_shim::trim( + &self.most_recently_entered_value(&full_choice), + None, + )); + output.borrow().write( + &[format!( + "<hl>{}</hl>", + OutputFormatter::escape_trailing_backslash(&shirabe_php_shim::substr( + &matches[ofs as usize].to_string(), + characters_entered, + None, + )) + )], + false, + output_interface::OUTPUT_NORMAL, + ); + cursor.restore_position(); + } + } + + // Reset stty so it behaves normally again + shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode)); + + full_choice + } + + fn most_recently_entered_value(&self, entered: &str) -> String { + // Determine the most recent value that the user entered + if !shirabe_php_shim::str_contains(entered, ",") { + return entered.to_string(); + } + + let choices = shirabe_php_shim::explode(",", entered); + let last_choice = shirabe_php_shim::trim(&choices[choices.len() - 1], None); + if !last_choice.is_empty() { + return last_choice; + } + + entered.to_string() + } + + /// Gets a hidden response from user. + /// + /// @param resource $inputStream The handler resource + /// @param bool $trimmable Is the answer trimmable + /// + /// @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden + fn get_hidden_response( &self, - _input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, - _output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, - _question: &Question, + output: Rc<RefCell<dyn OutputInterface>>, + input_stream: &shirabe_php_shim::PhpResource, + trimmable: bool, + ) -> anyhow::Result<Result<String, RuntimeException>> { + if shirabe_php_shim::DIRECTORY_SEPARATOR == "\\" { + let mut exe = format!( + "{}/../Resources/bin/hiddeninput.exe", + shirabe_php_shim::dir() + ); + + // handle code running from a phar + let mut tmp_exe: Option<String> = None; + if shirabe_php_shim::substr(&magic_file(), 0, Some(5)) == "phar:" { + let tmp = format!("{}/hiddeninput.exe", shirabe_php_shim::sys_get_temp_dir()); + shirabe_php_shim::copy(&exe, &tmp); + exe = tmp.clone(); + tmp_exe = Some(tmp); + } + + let s_exec = shirabe_php_shim::shell_exec(&format!("\"{}\"", exe)).unwrap_or_default(); + let value = if trimmable { + shirabe_php_shim::rtrim(&s_exec, None) + } else { + s_exec + }; + output + .borrow() + .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); + + if let Some(tmp) = tmp_exe { + shirabe_php_shim::unlink(&tmp); + } + + return Ok(Ok(value)); + } + + let mut stty_mode = String::new(); + if STTY.load(std::sync::atomic::Ordering::SeqCst) && Terminal::has_stty_available() { + stty_mode = shirabe_php_shim::shell_exec("stty -g").unwrap_or_default(); + shirabe_php_shim::shell_exec("stty -echo"); + } else if self.is_interactive_input(input_stream) { + return Ok(Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: "Unable to hide the response.".to_string(), + code: 0, + }))); + } + + let value = fgets_resource(input_stream, Some(4096)); + + if STTY.load(std::sync::atomic::Ordering::SeqCst) && Terminal::has_stty_available() { + shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode)); + } + + let mut value = match value { + Some(value) => value, + None => { + return Err(MissingInputException(RuntimeException( + shirabe_php_shim::RuntimeException { + message: "Aborted.".to_string(), + code: 0, + }, + )) + .into()); + } + }; + if trimmable { + value = shirabe_php_shim::trim(&value, None); + } + output + .borrow() + .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); + + Ok(Ok(value)) + } + + /// Validates an attempt. + /// + /// @param callable $interviewer A callable that will ask for a question and return the result + /// + /// @return mixed The validated response + /// + /// @throws \Exception In case the max number of attempts has been reached and no valid response has been given + fn validate_attempts( + &self, + interviewer: &dyn Fn() -> anyhow::Result<Result<PhpMixed, MissingInputException>>, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) -> anyhow::Result<Result<PhpMixed, MissingInputException>> { + let mut error: Option<shirabe_php_shim::Exception> = None; + let mut attempts = question.get_max_attempts(); + + loop { + // while (null === $attempts || $attempts--) + match attempts { + None => {} + Some(0) => break, + Some(n) => attempts = Some(n - 1), + } + + if let Some(ref error) = error { + self.write_error(Rc::clone(&output), error); + } + + let interviewed = match interviewer()? { + Ok(value) => value, + Err(missing) => return Ok(Err(missing)), + }; + + match question.get_validator().unwrap()(Some(interviewed)) { + Ok(value) => return Ok(Ok(value)), + Err(e) => { + // PHP: `catch (RuntimeException $e) { throw $e; } catch (\Exception $error) {}`. + // The validator return type is fixed to InvalidArgumentException here, so the + // RuntimeException rethrow branch is statically unreachable; record the error + // and retry. + error = Some(shirabe_php_shim::Exception { + message: e.0.message.clone(), + code: e.0.code, + }); + } + } + } + + // throw $error; + Err(anyhow::Error::msg( + error.map(|e| e.message).unwrap_or_default(), + )) + } + + fn is_interactive_input(&self, input_stream: &shirabe_php_shim::PhpResource) -> bool { + let uri = shirabe_php_shim::stream_get_meta_data(input_stream) + .get("uri") + .map(|uri| uri.to_string()); + if uri.as_deref() != Some("php://stdin") { + return false; + } + + let mut stdin_is_interactive = STDIN_IS_INTERACTIVE.lock().unwrap(); + if let Some(value) = *stdin_is_interactive { + return value; + } + + let value = shirabe_php_shim::stream_isatty_resource( + &shirabe_php_shim::php_fopen_resource("php://stdin", "r"), + ); + *stdin_is_interactive = Some(value); + value + } + + /// Reads one or more lines of input and returns what is read. + /// + /// @param resource $inputStream The handler resource + /// @param Question $question The question being asked + /// + /// @return string|false The input received, false in case input could not be read + fn read_input( + &self, + input_stream: &shirabe_php_shim::PhpResource, + question: &Question, ) -> PhpMixed { - todo!() + if !question.is_multiline() { + let cp = self.set_io_codepage(); + let ret = fgets_resource(input_stream, Some(4096)); + + return self.reset_io_codepage( + cp, + ret.map(PhpMixed::String).unwrap_or(PhpMixed::Bool(false)), + ); + } + + let multi_line_stream_reader = self.clone_input_stream(input_stream); + let multi_line_stream_reader = match multi_line_stream_reader { + Some(reader) => reader, + None => return PhpMixed::Bool(false), + }; + + let mut ret = String::new(); + let cp = self.set_io_codepage(); + loop { + let char = shirabe_php_shim::fgetc(&multi_line_stream_reader); + let char = match char { + Some(char) => char, + None => break, + }; + if shirabe_php_shim::PHP_EOL == format!("{}{}", ret, char) { + break; + } + ret.push_str(&char); + } + + self.reset_io_codepage(cp, PhpMixed::String(ret)) + } + + /// Sets console I/O to the host code page. + /// + /// @return int Previous code page in IBM/EBCDIC format + fn set_io_codepage(&self) -> i64 { + if shirabe_php_shim::function_exists("sapi_windows_cp_set") { + let cp = shirabe_php_shim::sapi_windows_cp_get(None); + shirabe_php_shim::sapi_windows_cp_set(shirabe_php_shim::sapi_windows_cp_get(Some( + "oem", + ))); + + return cp; + } + + 0 } + + /// Sets console I/O to the specified code page and converts the user input. + /// + /// @param string|false $input + /// + /// @return string|false + fn reset_io_codepage(&self, cp: i64, input: PhpMixed) -> PhpMixed { + let mut input = input; + if 0 != cp { + shirabe_php_shim::sapi_windows_cp_set(cp); + + if !matches!(input, PhpMixed::Bool(false)) && input.to_string() != "" { + input = PhpMixed::String(shirabe_php_shim::sapi_windows_cp_conv( + shirabe_php_shim::sapi_windows_cp_get(Some("oem")), + cp, + &input.to_string(), + )); + } + } + + input + } + + /// Clones an input stream in order to act on one instance of the same + /// stream without affecting the other instance. + /// + /// @param resource $inputStream The handler resource + /// + /// @return resource|null The cloned resource, null in case it could not be cloned + fn clone_input_stream( + &self, + input_stream: &shirabe_php_shim::PhpResource, + ) -> Option<shirabe_php_shim::PhpResource> { + let stream_meta_data = shirabe_php_shim::stream_get_meta_data(input_stream); + let seekable = stream_meta_data + .get("seekable") + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Bool(false)); + let mode = stream_meta_data + .get("mode") + .map(|m| m.to_string()) + .unwrap_or_else(|| "rb".to_string()); + let uri = stream_meta_data.get("uri").map(|u| u.to_string()); + + let uri = uri?; + + let clone_stream = shirabe_php_shim::php_fopen_resource(&uri, &mode); + + // For seekable and writable streams, add all the same data to the + // cloned stream and then seek to the same offset. + if matches!(seekable, PhpMixed::Bool(true)) && !["r", "rb", "rt"].contains(&mode.as_str()) { + let offset = shirabe_php_shim::ftell(input_stream); + rewind_resource(input_stream); + stream_copy_to_stream_resource(input_stream, &clone_stream); + fseek_resource(input_stream, offset); + fseek_resource(&clone_stream, offset); + } + + Some(clone_stream) + } + + /// Helper::substr proxy (inherited static helper). + fn substr(string: Option<&str>, from: i64, length: Option<i64>) -> String { + Helper::substr(string.unwrap_or(""), from, length) + } +} + +/// Downcasts a Question to a ChoiceQuestion if applicable. +fn question_as_choice_question(question: &Question) -> Option<&ChoiceQuestion> { + question.as_any().downcast_ref::<ChoiceQuestion>() +} + +// PHP operates on raw stream resources, but the shim models `feof`/`fread`/ +// `fgets`/`fseek`/`rewind`/`stream_copy_to_stream` over `PhpMixed`, which has no +// resource variant. These thin resource-typed wrappers stand in until the shim +// grows `*_resource` counterparts (see report). Each defers to the real +// implementation once it exists. +// PHP `__FILE__` magic constant. The shim's `file()` is PHP's file() function, +// not the magic constant, and there is no `__FILE__` shim yet (see report). +fn magic_file() -> String { + todo!("magic_file: shim needs a __FILE__ magic-constant equivalent") +} + +fn feof_resource(_stream: &shirabe_php_shim::PhpResource) -> bool { + todo!("feof_resource: shim needs a PhpResource-based feof") +} + +fn fread_resource(_stream: &shirabe_php_shim::PhpResource, _length: i64) -> Option<String> { + todo!("fread_resource: shim needs a PhpResource-based fread") +} + +fn fgets_resource(_stream: &shirabe_php_shim::PhpResource, _length: Option<i64>) -> Option<String> { + todo!("fgets_resource: shim needs a PhpResource-based fgets") +} + +fn fseek_resource(_stream: &shirabe_php_shim::PhpResource, _offset: i64) -> i64 { + todo!("fseek_resource: shim needs a PhpResource-based fseek") +} + +fn rewind_resource(_stream: &shirabe_php_shim::PhpResource) -> bool { + todo!("rewind_resource: shim needs a PhpResource-based rewind") +} + +fn stream_copy_to_stream_resource( + _source: &shirabe_php_shim::PhpResource, + _dest: &shirabe_php_shim::PhpResource, +) -> Option<i64> { + todo!("stream_copy_to_stream_resource: shim needs a PhpResource-based stream_copy_to_stream") } impl HelperInterface for QuestionHelper { - fn as_any(&self) -> &dyn std::any::Any { - self + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + self.get_name() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/symfony_question_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/symfony_question_helper.rs new file mode 100644 index 0000000..dd14513 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/symfony_question_helper.rs @@ -0,0 +1,182 @@ +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::helper::question_helper::QuestionHelper; +use crate::symfony::console::output::output_interface; +use crate::symfony::console::output::output_interface::OutputInterface; +use crate::symfony::console::question::choice_question::ChoiceQuestion; +use crate::symfony::console::question::confirmation_question::ConfirmationQuestion; +use crate::symfony::console::question::question::Question; +use crate::symfony::console::style::symfony_style::SymfonyStyle; +use shirabe_php_shim::AsAny; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +/// Symfony Style Guide compliant question helper. +#[derive(Debug, Default)] +pub struct SymfonyQuestionHelper { + inner: QuestionHelper, +} + +impl SymfonyQuestionHelper { + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn write_prompt( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) { + let mut text = OutputFormatter::escape_trailing_backslash(&question.get_question()); + let default = question.get_default(); + + if question.is_multiline() { + text += &shirabe_php_shim::sprintf( + " (press %s to continue)", + &[PhpMixed::String(self.get_eof_shortcut())], + ); + } + + // switch (true) + if matches!(default, PhpMixed::Null) { + text = shirabe_php_shim::sprintf(" <info>%s</info>:", &[PhpMixed::String(text)]); + } else if question + .as_any() + .downcast_ref::<ConfirmationQuestion>() + .is_some() + { + text = shirabe_php_shim::sprintf( + " <info>%s (yes/no)</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String( + if shirabe_php_shim::boolval(&default) { + "yes" + } else { + "no" + } + .to_string(), + ), + ], + ); + } else if let Some(choice_question) = question + .as_any() + .downcast_ref::<ChoiceQuestion>() + .filter(|q| q.is_multiselect()) + { + let choices = choice_question.get_choices(); + let default_parts = shirabe_php_shim::explode(",", &default.to_string()); + + let resolved: Vec<String> = default_parts + .iter() + .map(|value| { + choices + .get(&shirabe_php_shim::trim(value, None)) + .map(|v| v.to_string()) + .unwrap() + }) + .collect(); + + text = shirabe_php_shim::sprintf( + " <info>%s</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String(OutputFormatter::escape(&resolved.join(", ")).unwrap()), + ], + ); + } else if let Some(choice_question) = question.as_any().downcast_ref::<ChoiceQuestion>() { + let choices = choice_question.get_choices(); + text = shirabe_php_shim::sprintf( + " <info>%s</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String( + OutputFormatter::escape( + &choices + .get(&default.to_string()) + .map(|v| (**v).clone()) + .unwrap_or(default.clone()) + .to_string(), + ) + .unwrap(), + ), + ], + ); + } else { + text = shirabe_php_shim::sprintf( + " <info>%s</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String(OutputFormatter::escape(&default.to_string()).unwrap()), + ], + ); + } + + output + .borrow() + .writeln(&[text], output_interface::OUTPUT_NORMAL); + + let mut prompt = " > ".to_string(); + + if let Some(choice_question) = question.as_any().downcast_ref::<ChoiceQuestion>() { + output.borrow().writeln( + &self + .inner + .format_choice_question_choices(choice_question, "comment"), + output_interface::OUTPUT_NORMAL, + ); + + prompt = choice_question.get_prompt().to_string(); + } + + output + .borrow() + .write(&[prompt], false, output_interface::OUTPUT_NORMAL); + } + + pub(crate) fn write_error( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + error: &shirabe_php_shim::Exception, + ) { + let is_symfony_style = { + let borrowed = output.borrow(); + (*borrowed) + .as_any() + .downcast_ref::<SymfonyStyle>() + .is_some() + }; + if is_symfony_style { + // $output->newLine(); $output->error($error->getMessage()); + // SymfonyStyle's newLine()/error() require mutable access to the + // concrete type; mutable downcasting through the trait object is + // resolved in a later phase. + todo!("SymfonyStyle newLine()/error() require &mut SymfonyStyle"); + } + + self.inner.write_error(output, error); + } + + fn get_eof_shortcut(&self) -> String { + if shirabe_php_shim::php_os_family() == "Windows" { + return "<comment>Ctrl+Z</comment> then <comment>Enter</comment>".to_string(); + } + + "<comment>Ctrl+D</comment>".to_string() + } +} + +impl Deref for SymfonyQuestionHelper { + type Target = QuestionHelper; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for SymfonyQuestionHelper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs index 4ff5a5e..6f52458 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs @@ -1,47 +1,1356 @@ -use crate::symfony::console::output::OutputInterface; +use crate::composer::pcre::preg::Preg; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::table_cell::{TableCell, TableCellOption}; +use crate::symfony::console::helper::table_cell_style::TableCellStyle; +use crate::symfony::console::helper::table_rows::TableRows; +use crate::symfony::console::helper::table_separator::TableSeparator; +use crate::symfony::console::helper::table_style::TableStyle; +use crate::symfony::console::output::console_section_output::ConsoleSectionOutput; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; +/// Provides helpers to display a table. #[derive(Debug)] -pub struct Table; +pub struct Table { + header_title: Option<String>, + footer_title: Option<String>, + + /// Table headers. + headers: Vec<PhpMixed>, + + /// Table rows. + rows: Vec<PhpMixed>, + horizontal: bool, + + /// Column widths cache. + effective_column_widths: IndexMap<i64, i64>, + + /// Number of columns cache. + number_of_columns: Option<i64>, + + output: Rc<RefCell<dyn OutputInterface>>, + + style: TableStyle, + + column_styles: IndexMap<i64, TableStyle>, + + /// User set column widths. + column_widths: IndexMap<i64, i64>, + column_max_widths: IndexMap<i64, i64>, + + rendered: bool, +} + +const SEPARATOR_TOP: i64 = 0; +const SEPARATOR_TOP_BOTTOM: i64 = 1; +const SEPARATOR_MID: i64 = 2; +const SEPARATOR_BOTTOM: i64 = 3; +const BORDER_OUTSIDE: i64 = 0; +const BORDER_INSIDE: i64 = 1; + +/// Global style definitions, lazily initialized. +/// +/// In PHP this is `private static $styles`. Here it is a process-global cache. +fn styles() -> &'static std::sync::Mutex<Option<IndexMap<String, TableStyle>>> { + static STYLES: std::sync::Mutex<Option<IndexMap<String, TableStyle>>> = + std::sync::Mutex::new(None); + &STYLES +} impl Table { - pub fn new(_output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) -> Self { + pub fn new(output: Rc<RefCell<dyn OutputInterface>>) -> Self { + let mut styles_guard = styles().lock().unwrap(); + if styles_guard.is_none() { + *styles_guard = Some(Self::init_styles()); + } + drop(styles_guard); + + let mut this = Self { + header_title: None, + footer_title: None, + headers: Vec::new(), + rows: Vec::new(), + horizontal: false, + effective_column_widths: IndexMap::new(), + number_of_columns: None, + output, + style: TableStyle::default(), + column_styles: IndexMap::new(), + column_widths: IndexMap::new(), + column_max_widths: IndexMap::new(), + rendered: false, + }; + + this.set_style(PhpMixed::from("default")); + + this + } + + /// Sets a style definition. + pub fn set_style_definition(name: String, style: TableStyle) { + let mut styles_guard = styles().lock().unwrap(); + if styles_guard.is_none() { + *styles_guard = Some(Self::init_styles()); + } + + styles_guard.as_mut().unwrap().insert(name, style); + } + + /// Gets a style definition by name. + pub fn get_style_definition( + name: String, + ) -> anyhow::Result<Result<TableStyle, InvalidArgumentException>> { + let mut styles_guard = styles().lock().unwrap(); + if styles_guard.is_none() { + *styles_guard = Some(Self::init_styles()); + } + + if let Some(_style) = styles_guard.as_ref().unwrap().get(&name) { + // TODO(phase-b): TableStyle is not Clone; sharing semantics need resolving. + todo!() + } + + Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Style \"%s\" is not defined.", + &[PhpMixed::from(name)], + ), + code: 0, + }, + ))) + } + + /// Sets table style. + /// + /// `$name` is the style name or a TableStyle instance. + pub fn set_style( + &mut self, + name: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + match self.resolve_style(name)? { + Ok(style) => { + self.style = style; + Ok(Ok(self)) + } + Err(e) => Ok(Err(e)), + } + } + + /// Gets the current table style. + pub fn get_style(&self) -> &TableStyle { + &self.style + } + + /// Sets table column style. + /// + /// `$name` is the style name or a TableStyle instance. + pub fn set_column_style( + &mut self, + column_index: i64, + name: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + match self.resolve_style(name)? { + Ok(style) => { + self.column_styles.insert(column_index, style); + Ok(Ok(self)) + } + Err(e) => Ok(Err(e)), + } + } + + /// Gets the current style for a column. + /// + /// If style was not set, it returns the global table style. + pub fn get_column_style(&self, column_index: i64) -> &TableStyle { + self.column_styles + .get(&column_index) + .unwrap_or_else(|| self.get_style()) + } + + /// Sets the minimum width of a column. + pub fn set_column_width(&mut self, column_index: i64, width: i64) -> &mut Self { + self.column_widths.insert(column_index, width); + + self + } + + /// Sets the minimum width of all columns. + pub fn set_column_widths(&mut self, widths: Vec<i64>) -> &mut Self { + self.column_widths = IndexMap::new(); + for (index, width) in widths.into_iter().enumerate() { + self.set_column_width(index as i64, width); + } + + self + } + + /// Sets the maximum width of a column. + /// + /// Any cell within this column which contents exceeds the specified width will be wrapped into + /// multiple lines, while formatted strings are preserved. + pub fn set_column_max_width(&mut self, column_index: i64, width: i64) -> &mut Self { + if !Self::formatter_is_wrappable(&self.output) { + // PHP throws \LogicException here. This represents a programming error: the caller must + // supply a WrappableOutputFormatterInterface before setting a maximum column width. + panic!( + "Setting a maximum column width is only supported when using a \"{}\" formatter, got \"{}\".", + "Symfony\\Component\\Console\\Formatter\\WrappableOutputFormatterInterface", + shirabe_php_shim::get_debug_type(&PhpMixed::from(())) + ); + } + + self.column_max_widths.insert(column_index, width); + + self + } + + pub fn set_headers(&mut self, headers: Vec<PhpMixed>) -> &mut Self { + // PHP: $headers = array_values($headers). A row is already modeled as a positional Vec, + // so reindexing is the identity here. + let mut headers = headers; + if !headers.is_empty() && !shirabe_php_shim::is_array(&headers[0]) { + headers = vec![Self::from_row_vec(headers)]; + } + + self.headers = headers; + + self + } + + pub fn set_rows(&mut self, rows: Vec<PhpMixed>) -> &mut Self { + self.rows = Vec::new(); + + self.add_rows(rows) + } + + pub fn add_rows(&mut self, rows: Vec<PhpMixed>) -> &mut Self { + for row in rows { + self.add_row(row); + } + + self + } + + pub fn add_row( + &mut self, + row: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + if shirabe_php_shim::instance_of::<TableSeparator>(&row) { + self.rows.push(row); + + return Ok(Ok(self)); + } + + if !shirabe_php_shim::is_array(&row) { + return Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "A row must be an array or a TableSeparator instance.".to_string(), + code: 0, + }, + ))); + } + + // PHP: $this->rows[] = array_values($row). The row is modeled as a positional Vec. + self.rows.push(Self::from_row_vec(Self::to_row_vec(row))); + + Ok(Ok(self)) + } + + /// Adds a row to the table, and re-renders the table. + pub fn append_row( + &mut self, + row: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, RuntimeException>> { + if !Self::output_is_console_section(&self.output) { + return Ok(Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "Output should be an instance of \"%s\" when calling \"%s\".", + &[ + PhpMixed::from("Symfony\\Component\\Console\\Output\\ConsoleSectionOutput"), + PhpMixed::from("Symfony\\Component\\Console\\Helper\\Table::appendRow"), + ], + ), + code: 0, + }))); + } + + if self.rendered { + // TODO(phase-b): downcast output to ConsoleSectionOutput to call clear(). + let _ = ConsoleSectionOutput::clear; + let row_count = self.calculate_row_count(); + let _ = row_count; + todo!() + } + + self.add_row(row)?.ok(); + self.render(); + + Ok(Ok(self)) + } + + pub fn set_row(&mut self, column: i64, row: Vec<PhpMixed>) -> &mut Self { + // PHP indexes $this->rows by arbitrary key; here we follow the integer-keyed case. + let _ = (column, row); todo!() } - pub fn set_headers(&mut self, _headers: Vec<PhpMixed>) -> &mut Self { + pub fn set_header_title(&mut self, title: Option<String>) -> &mut Self { + self.header_title = title; + + self + } + + pub fn set_footer_title(&mut self, title: Option<String>) -> &mut Self { + self.footer_title = title; + + self + } + + pub fn set_horizontal(&mut self, horizontal: bool) -> &mut Self { + self.horizontal = horizontal; + + self + } + + /// Renders table to output. + pub fn render(&mut self) { + let divider = TableSeparator::new(); + let rows: Vec<PhpMixed>; + if self.horizontal { + let mut horizontal_rows: IndexMap<i64, Vec<PhpMixed>> = IndexMap::new(); + let header0 = self + .headers + .first() + .map(|h| Self::to_row_vec(h.clone())) + .unwrap_or_default(); + for (i, header) in header0.into_iter().enumerate() { + let i = i as i64; + horizontal_rows.insert(i, vec![header]); + for row in &self.rows { + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + continue; + } + if let Some(cell) = Self::row_get(row, i) { + let entry = horizontal_rows.get_mut(&i).unwrap(); + entry.push(cell); + } else { + let first = horizontal_rows.get(&i).unwrap().first().cloned(); + let is_title_noop = match first { + Some(ref c) if shirabe_php_shim::instance_of::<TableCell>(c) => { + Self::cell_colspan(c) >= 2 + } + _ => false, + }; + if is_title_noop { + // Noop, there is a "title" + } else { + let entry = horizontal_rows.get_mut(&i).unwrap(); + entry.push(PhpMixed::from(())); + } + } + } + } + rows = horizontal_rows + .into_values() + .map(Self::from_row_vec) + .collect(); + } else { + let mut merged = self.headers.clone(); + merged.push(Self::table_separator_to_mixed(divider.clone())); + merged.extend(self.rows.clone()); + rows = merged; + } + + self.calculate_number_of_columns(&rows); + + let row_groups = self.build_table_rows(rows); + self.calculate_columns_width(&row_groups); + + let mut is_header = !self.horizontal; + let mut is_first_row = self.horizontal; + let mut has_title = + self.header_title.is_some() && !self.header_title.as_deref().unwrap_or("").is_empty(); + + for row_group in &row_groups { + let mut is_header_separator_rendered = false; + + for row in row_group { + if Self::is_divider(row, ÷r) { + is_header = false; + is_first_row = true; + + continue; + } + + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + self.render_row_separator(SEPARATOR_MID, None, None); + + continue; + } + + if !shirabe_php_shim::to_bool(row) { + continue; + } + + if is_header && !is_header_separator_rendered { + self.render_row_separator( + if is_header { + SEPARATOR_TOP + } else { + SEPARATOR_TOP_BOTTOM + }, + if has_title { + self.header_title.clone() + } else { + None + }, + if has_title { + Some(self.style.get_header_title_format()) + } else { + None + }, + ); + has_title = false; + is_header_separator_rendered = true; + } + + if is_first_row { + self.render_row_separator( + if is_header { + SEPARATOR_TOP + } else { + SEPARATOR_TOP_BOTTOM + }, + if has_title { + self.header_title.clone() + } else { + None + }, + if has_title { + Some(self.style.get_header_title_format()) + } else { + None + }, + ); + is_first_row = false; + has_title = false; + } + + if self.horizontal { + self.render_row( + Self::to_row_vec(row.clone()), + self.style.get_cell_row_format(), + Some(self.style.get_cell_header_format()), + ); + } else { + self.render_row( + Self::to_row_vec(row.clone()), + if is_header { + self.style.get_cell_header_format() + } else { + self.style.get_cell_row_format() + }, + None, + ); + } + } + } + self.render_row_separator( + SEPARATOR_BOTTOM, + self.footer_title.clone(), + Some(self.style.get_footer_title_format()), + ); + + self.cleanup(); + self.rendered = true; + } + + /// Renders horizontal header separator. + fn render_row_separator( + &self, + r#type: i64, + title: Option<String>, + title_format: Option<String>, + ) { + let count = match self.number_of_columns { + Some(0) | None => return, + Some(c) => c, + }; + + let borders = self.style.get_border_chars(); + if borders[0].is_empty() + && borders[2].is_empty() + && self.style.get_crossing_char().is_empty() + { + return; + } + + let crossings = self.style.get_crossing_chars(); + let (horizontal, left_char, mid_char, right_char) = if SEPARATOR_MID == r#type { + ( + borders[2].clone(), + crossings[8].clone(), + crossings[0].clone(), + crossings[4].clone(), + ) + } else if SEPARATOR_TOP == r#type { + ( + borders[0].clone(), + crossings[1].clone(), + crossings[2].clone(), + crossings[3].clone(), + ) + } else if SEPARATOR_TOP_BOTTOM == r#type { + ( + borders[0].clone(), + crossings[9].clone(), + crossings[10].clone(), + crossings[11].clone(), + ) + } else { + ( + borders[0].clone(), + crossings[7].clone(), + crossings[6].clone(), + crossings[5].clone(), + ) + }; + + let mut markup = left_char; + let mut column = 0; + while column < count { + markup.push_str(&shirabe_php_shim::str_repeat( + &horizontal, + self.effective_column_widths[&column] as usize, + )); + markup.push_str(if column == count - 1 { + &right_char + } else { + &mid_char + }); + column += 1; + } + + if let Some(title) = title { + let title_format = title_format.unwrap(); + let formatted_title = + shirabe_php_shim::sprintf(&title_format, &[PhpMixed::from(title.clone())]); + let mut formatted_title = formatted_title; + let mut title_length = Helper::width(&self.remove_decoration(&formatted_title)); + let markup_length = Helper::width(&markup); + let limit = markup_length - 4; + if title_length > limit { + title_length = limit; + let format_length = Helper::width(&self.remove_decoration( + &shirabe_php_shim::sprintf(&title_format, &[PhpMixed::from("")]), + )); + formatted_title = shirabe_php_shim::sprintf( + &title_format, + &[PhpMixed::from(format!( + "{}...", + Helper::substr(&title, 0, Some(limit - format_length - 3)) + ))], + ); + } + + let title_start = shirabe_php_shim::intdiv(markup_length - title_length, 2); + if shirabe_php_shim::mb_detect_encoding(&markup, None, true).is_none() { + markup = shirabe_php_shim::substr_replace( + &markup, + &formatted_title, + title_start as usize, + title_length as usize, + ); + } else { + markup = format!( + "{}{}{}", + shirabe_php_shim::mb_substr(&markup, 0, Some(title_start), None), + formatted_title, + shirabe_php_shim::mb_substr(&markup, title_start + title_length, None, None), + ); + } + } + + self.output.borrow().writeln( + &[shirabe_php_shim::sprintf( + &self.style.get_border_format(), + &[PhpMixed::from(markup)], + )], + crate::symfony::console::output::output_interface::OUTPUT_NORMAL, + ); + } + + /// Renders vertical column separator. + fn render_column_separator(&self, r#type: i64) -> String { + let borders = self.style.get_border_chars(); + + shirabe_php_shim::sprintf( + &self.style.get_border_format(), + &[PhpMixed::from(if BORDER_OUTSIDE == r#type { + borders[1].clone() + } else { + borders[3].clone() + })], + ) + } + + /// Renders table row. + fn render_row( + &self, + row: Vec<PhpMixed>, + cell_format: String, + first_cell_format: Option<String>, + ) { + let mut row_content = self.render_column_separator(BORDER_OUTSIDE); + let columns = self.get_row_columns(&row); + let last = columns.len() as i64 - 1; + for (i, column) in columns.into_iter().enumerate() { + let i = i as i64; + if first_cell_format.is_some() && 0 == i { + row_content.push_str(&self.render_cell( + &row, + column, + first_cell_format.clone().unwrap(), + )); + } else { + row_content.push_str(&self.render_cell(&row, column, cell_format.clone())); + } + row_content.push_str(&self.render_column_separator(if last == i { + BORDER_OUTSIDE + } else { + BORDER_INSIDE + })); + } + self.output.borrow().writeln( + &[row_content], + crate::symfony::console::output::output_interface::OUTPUT_NORMAL, + ); + } + + /// Renders table cell with padding. + fn render_cell(&self, row: &[PhpMixed], column: i64, cell_format: String) -> String { + let cell = Self::row_get_index(row, column).unwrap_or_else(|| PhpMixed::from("")); + let mut width = self.effective_column_widths[&column]; + if shirabe_php_shim::instance_of::<TableCell>(&cell) && Self::cell_colspan(&cell) > 1 { + // add the width of the following columns(numbers of colspan). + for next_column in (column + 1)..=(column + Self::cell_colspan(&cell) - 1) { + width += + self.get_column_separator_width() + self.effective_column_widths[&next_column]; + } + } + + // str_pad won't work properly with multi-byte strings, we need to fix the padding + let cell_str = shirabe_php_shim::to_string(&cell); + if let Some(encoding) = shirabe_php_shim::mb_detect_encoding(&cell_str, None, true) { + width += shirabe_php_shim::strlen(&cell_str) + - shirabe_php_shim::mb_strwidth(&cell_str, Some(&encoding)); + } + + let style = self.get_column_style(column).clone(); + + if shirabe_php_shim::instance_of::<TableSeparator>(&cell) { + return shirabe_php_shim::sprintf( + &style.get_border_format(), + &[PhpMixed::from(shirabe_php_shim::str_repeat( + &style.get_border_chars()[2], + width as usize, + ))], + ); + } + + width += Helper::length(&cell_str) - Helper::length(&self.remove_decoration(&cell_str)); + let mut content = shirabe_php_shim::sprintf( + &style.get_cell_row_content_format(), + &[PhpMixed::from(cell_str.clone())], + ); + + let mut cell_format = cell_format; + let mut pad_type = style.get_pad_type(); + if shirabe_php_shim::instance_of::<TableCell>(&cell) + && Self::cell_style_is_table_cell_style(&cell) + { + let is_not_styled_by_tag = !Preg::is_match( + "/^<(\\w+|(\\w+=[\\w,]+;?)*)>.+<\\/(\\w+|(\\w+=\\w+;?)*)?>$/", + &cell_str, + ) + .unwrap(); + if is_not_styled_by_tag { + let cell_style = Self::cell_get_style(&cell).unwrap(); + match cell_style.get_cell_format() { + Some(fmt) => cell_format = fmt, + None => { + let tag = shirabe_php_shim::http_build_query_mixed( + &cell_style.get_tag_options(), + "", + ";", + ); + cell_format = format!("<{}>%s</>", tag); + } + } + + if shirabe_php_shim::strstr(&content, "</>").is_some() { + content = shirabe_php_shim::str_replace("</>", "", &content); + width -= 3; + } + if shirabe_php_shim::strstr(&content, "<fg=default;bg=default>").is_some() { + content = + shirabe_php_shim::str_replace("<fg=default;bg=default>", "", &content); + width -= shirabe_php_shim::strlen("<fg=default;bg=default>"); + } + } + + pad_type = Self::cell_get_style(&cell).unwrap().get_pad_by_align(); + } + + shirabe_php_shim::sprintf( + &cell_format, + &[PhpMixed::from(shirabe_php_shim::str_pad( + &content, + width as usize, + &style.get_padding_char(), + pad_type, + ))], + ) + } + + /// Calculate number of columns for this table. + fn calculate_number_of_columns(&mut self, rows: &[PhpMixed]) { + let mut columns = vec![0i64]; + for row in rows { + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + continue; + } + + columns.push(self.get_number_of_columns(&Self::to_row_vec(row.clone()))); + } + + self.number_of_columns = Some(*columns.iter().max().unwrap()); + } + + fn build_table_rows(&mut self, rows: Vec<PhpMixed>) -> TableRows { + let mut rows = rows; + let mut unmerged_rows: IndexMap<i64, IndexMap<i64, Vec<PhpMixed>>> = IndexMap::new(); + let mut row_key = 0i64; + while row_key < rows.len() as i64 { + rows = self.fill_next_rows(rows, row_key); + + // Remove any new line breaks and replace it with a new line + let current = Self::to_row_vec(rows[row_key as usize].clone()); + for (column, cell) in current.iter().enumerate() { + let column = column as i64; + let mut cell = cell.clone(); + let colspan = if shirabe_php_shim::instance_of::<TableCell>(&cell) { + Self::cell_colspan(&cell) + } else { + 1 + }; + + if self.column_max_widths.contains_key(&column) + && Helper::width(&self.remove_decoration(&shirabe_php_shim::to_string(&cell))) + > self.column_max_widths[&column] + { + // TODO(phase-b): formatAndWrap requires a WrappableOutputFormatterInterface; + // downcasting dyn OutputFormatterInterface to it needs concrete knowledge. + let _ = colspan; + let wrapped: Option<String> = todo!(); + cell = PhpMixed::from(wrapped.unwrap_or_default()); + } + let cell_str = shirabe_php_shim::to_string(&cell); + if shirabe_php_shim::strstr(&cell_str, "\n").is_none() { + continue; + } + let eol = if shirabe_php_shim::str_contains(&cell_str, "\r\n") { + "\r\n" + } else { + "\n" + }; + let escaped = shirabe_php_shim::implode( + eol, + &shirabe_php_shim::explode(eol, &cell_str) + .iter() + .map(|line| OutputFormatter::escape_trailing_backslash(line)) + .collect::<Vec<_>>(), + ); + cell = if shirabe_php_shim::instance_of::<TableCell>(&cell) { + Self::table_cell_to_mixed(TableCell::new2( + &escaped, + Self::table_cell_options_colspan(Self::cell_colspan(&cell)), + )) + } else { + PhpMixed::from(escaped.clone()) + }; + let lines = shirabe_php_shim::explode( + eol, + &shirabe_php_shim::str_replace( + eol, + &format!("<fg=default;bg=default></>{}", eol), + &shirabe_php_shim::to_string(&cell), + ), + ); + for (line_key, line) in lines.into_iter().enumerate() { + let line_key = line_key as i64; + let mut line = PhpMixed::from(line); + if colspan > 1 { + line = Self::table_cell_to_mixed(TableCell::new2( + &shirabe_php_shim::to_string(&line), + Self::table_cell_options_colspan(colspan), + )); + } + if 0 == line_key { + let mut r = Self::to_row_vec(rows[row_key as usize].clone()); + Self::array_set(&mut r, column, line); + rows[row_key as usize] = Self::from_row_vec(r); + } else { + if !unmerged_rows.contains_key(&row_key) + || !unmerged_rows[&row_key].contains_key(&line_key) + { + let copied = self.copy_row(&rows, row_key); + unmerged_rows + .entry(row_key) + .or_default() + .insert(line_key, copied); + } + let target = unmerged_rows + .get_mut(&row_key) + .unwrap() + .get_mut(&line_key) + .unwrap(); + Self::vec_set(target, column, line); + } + } + } + row_key += 1; + } + + // PHP returns a TableRows wrapping a generator that lazily yields row groups. + // The generator borrows $this to call fillCells(). In Phase A we precompute the + // row groups eagerly to preserve behavior, then hand them to TableRows. + let mut row_groups: Vec<Vec<PhpMixed>> = Vec::new(); + for (row_key, row) in rows.into_iter().enumerate() { + let row_key = row_key as i64; + let mut row_group: Vec<PhpMixed> = + vec![if shirabe_php_shim::instance_of::<TableSeparator>(&row) { + row + } else { + Self::from_row_vec(self.fill_cells(Self::to_row_vec(row))) + }]; + + if let Some(extra) = unmerged_rows.get(&row_key) { + for r in extra.values() { + let r = Self::from_row_vec(r.clone()); + row_group.push(if shirabe_php_shim::instance_of::<TableSeparator>(&r) { + r + } else { + Self::from_row_vec(self.fill_cells(Self::to_row_vec(r))) + }); + } + } + row_groups.push(row_group); + } + + TableRows::from_row_groups(row_groups) + } + + fn calculate_row_count(&mut self) -> i64 { + let mut merged = self.headers.clone(); + merged.push(Self::table_separator_to_mixed(TableSeparator::new())); + merged.extend(self.rows.clone()); + let mut number_of_rows = + shirabe_php_shim::iterator_to_array(self.build_table_rows(merged)).len() as i64; + + if !self.headers.is_empty() { + number_of_rows += 1; // Add row for header separator + } + + if !self.rows.is_empty() { + number_of_rows += 1; // Add row for footer separator + } + + number_of_rows + } + + /// fill rows that contains rowspan > 1. + fn fill_next_rows(&self, rows: Vec<PhpMixed>, line: i64) -> Vec<PhpMixed> { + let mut rows = rows; + let mut unmerged_rows: IndexMap<i64, IndexMap<i64, PhpMixed>> = IndexMap::new(); + let current = Self::to_row_vec(rows[line as usize].clone()); + for (column, cell) in current.iter().enumerate() { + let column = column as i64; + let cell = cell.clone(); + if !shirabe_php_shim::is_null(&cell) + && !shirabe_php_shim::instance_of::<TableCell>(&cell) + && !shirabe_php_shim::is_scalar(&cell) + && !(shirabe_php_shim::is_object(&cell) + && shirabe_php_shim::method_exists(&cell, "__toString")) + { + // PHP throws InvalidArgumentException; the @throws contract makes this a + // recoverable error. In Phase A we keep the panic placeholder as fill_next_rows + // does not yet return a Result in the call chain. + // TODO(phase-b): thread InvalidArgumentException through fill_next_rows. + panic!( + "A cell must be a TableCell, a scalar or an object implementing \"__toString()\", \"{}\" given.", + shirabe_php_shim::get_debug_type(&cell) + ); + } + if shirabe_php_shim::instance_of::<TableCell>(&cell) && Self::cell_rowspan(&cell) > 1 { + let mut nb_lines = Self::cell_rowspan(&cell) - 1; + let cell_str = shirabe_php_shim::to_string(&cell); + let mut lines = vec![cell.clone()]; + if shirabe_php_shim::strstr(&cell_str, "\n").is_some() { + let eol = if shirabe_php_shim::str_contains(&cell_str, "\r\n") { + "\r\n" + } else { + "\n" + }; + let exploded = shirabe_php_shim::explode( + eol, + &shirabe_php_shim::str_replace( + eol, + &format!("<fg=default;bg=default>{}</>", eol), + &cell_str, + ), + ); + lines = exploded.into_iter().map(PhpMixed::from).collect(); + nb_lines = if (lines.len() as i64) > nb_lines { + shirabe_php_shim::substr_count(&cell_str, eol) + } else { + nb_lines + }; + + let mut r = Self::to_row_vec(rows[line as usize].clone()); + Self::array_set( + &mut r, + column, + Self::table_cell_to_mixed(TableCell::new2( + &shirabe_php_shim::to_string(&lines[0]), + Self::table_cell_options_colspan_style( + Self::cell_colspan(&cell), + Self::cell_get_style(&cell), + ), + )), + ); + rows[line as usize] = Self::from_row_vec(r); + lines.remove(0); + } + + // create a two dimensional array (rowspan x colspan) + for k in (line + 1)..=(line + nb_lines) { + unmerged_rows.entry(k).or_default(); + } + for unmerged_row_key in unmerged_rows.keys().cloned().collect::<Vec<_>>() { + let idx = unmerged_row_key - line; + let value = lines + .get(idx as usize) + .cloned() + .unwrap_or_else(|| PhpMixed::from("")); + unmerged_rows.get_mut(&unmerged_row_key).unwrap().insert( + column, + Self::table_cell_to_mixed(TableCell::new2( + &shirabe_php_shim::to_string(&value), + Self::table_cell_options_colspan_style( + Self::cell_colspan(&cell), + Self::cell_get_style(&cell), + ), + )), + ); + if nb_lines == unmerged_row_key - line { + break; + } + } + } + } + + for (unmerged_row_key, unmerged_row) in unmerged_rows.clone() { + // we need to know if $unmergedRow will be merged or inserted into $rows + let fits = (unmerged_row_key as usize) < rows.len() + && shirabe_php_shim::is_array(&rows[unmerged_row_key as usize]) + && (self.get_number_of_columns(&Self::to_row_vec( + rows[unmerged_row_key as usize].clone(), + )) + self.get_number_of_columns( + &unmerged_rows[&unmerged_row_key] + .values() + .cloned() + .collect::<Vec<_>>(), + ) <= self.number_of_columns.unwrap()); + if fits { + let mut target = Self::to_row_vec(rows[unmerged_row_key as usize].clone()); + for (cell_key, cell) in unmerged_row { + // insert cell into row at cellKey position + shirabe_php_shim::array_splice(&mut target, cell_key, Some(0), vec![cell]); + } + rows[unmerged_row_key as usize] = Self::from_row_vec(target); + } else { + let mut row = self.copy_row(&rows, unmerged_row_key - 1); + for (column, cell) in &unmerged_row { + if shirabe_php_shim::to_bool(cell) { + Self::vec_set(&mut row, *column, unmerged_row[column].clone()); + } + } + shirabe_php_shim::array_splice_mixed( + &mut rows, + unmerged_row_key, + 0, + vec![Self::from_row_vec(row)], + ); + } + } + + rows + } + + /// fill cells for a row that contains colspan > 1. + fn fill_cells(&self, row: Vec<PhpMixed>) -> Vec<PhpMixed> { + let mut new_row: Vec<PhpMixed> = Vec::new(); + + for (column, cell) in row.iter().enumerate() { + let column = column as i64; + new_row.push(cell.clone()); + if shirabe_php_shim::instance_of::<TableCell>(cell) && Self::cell_colspan(cell) > 1 { + for _position in (column + 1)..=(column + Self::cell_colspan(cell) - 1) { + // insert empty value at column position + new_row.push(PhpMixed::from("")); + } + } + } + + if new_row.is_empty() { row } else { new_row } + } + + fn copy_row(&self, rows: &[PhpMixed], line: i64) -> Vec<PhpMixed> { + let mut row = Self::to_row_vec(rows[line as usize].clone()); + for cell_key in 0..row.len() { + let cell_value = row[cell_key].clone(); + row[cell_key] = PhpMixed::from(""); + if shirabe_php_shim::instance_of::<TableCell>(&cell_value) { + row[cell_key] = Self::table_cell_to_mixed(TableCell::new2( + "", + Self::table_cell_options_colspan(Self::cell_colspan(&cell_value)), + )); + } + } + + row + } + + /// Gets number of columns by row. + fn get_number_of_columns(&self, row: &[PhpMixed]) -> i64 { + let mut columns = row.len() as i64; + for column in row { + columns += if shirabe_php_shim::instance_of::<TableCell>(column) { + Self::cell_colspan(column) - 1 + } else { + 0 + }; + } + + columns + } + + /// Gets list of columns for the given row. + fn get_row_columns(&self, row: &[PhpMixed]) -> Vec<i64> { + let mut columns: Vec<i64> = (0..self.number_of_columns.unwrap()).collect(); + for (cell_key, cell) in row.iter().enumerate() { + let cell_key = cell_key as i64; + if shirabe_php_shim::instance_of::<TableCell>(cell) && Self::cell_colspan(cell) > 1 { + // exclude grouped columns. + let excluded: Vec<i64> = + ((cell_key + 1)..=(cell_key + Self::cell_colspan(cell) - 1)).collect(); + columns.retain(|c| !excluded.contains(c)); + } + } + + columns + } + + /// Calculates columns widths. + fn calculate_columns_width(&mut self, groups: &TableRows) { + let mut column = 0; + while column < self.number_of_columns.unwrap() { + let mut lengths: Vec<i64> = Vec::new(); + for group in groups { + for row in group { + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + continue; + } + + let mut row_arr = Self::to_row_vec(row.clone()); + for i in 0..row_arr.len() { + let cell = row_arr[i].clone(); + if shirabe_php_shim::instance_of::<TableCell>(&cell) { + let text_content = + self.remove_decoration(&shirabe_php_shim::to_string(&cell)); + let text_length = Helper::width(&text_content); + if text_length > 0 { + let content_columns = shirabe_php_shim::mb_str_split( + &text_content, + shirabe_php_shim::ceil( + text_length as f64 / Self::cell_colspan(&cell) as f64, + ) as i64, + ); + for (position, content) in content_columns.into_iter().enumerate() { + Self::vec_set( + &mut row_arr, + i as i64 + position as i64, + PhpMixed::from(content), + ); + } + } + } + } + + lengths.push(self.get_cell_width(&row_arr, column)); + } + } + + self.effective_column_widths.insert( + column, + *lengths.iter().max().unwrap() + + Helper::width(&self.style.get_cell_row_content_format()) + - 2, + ); + column += 1; + } + } + + fn get_column_separator_width(&self) -> i64 { + Helper::width(&shirabe_php_shim::sprintf( + &self.style.get_border_format(), + &[PhpMixed::from(self.style.get_border_chars()[3].clone())], + )) + } + + fn get_cell_width(&self, row: &[PhpMixed], column: i64) -> i64 { + let mut cell_width = 0; + + if let Some(cell) = Self::row_get_index(row, column) { + cell_width = + Helper::width(&self.remove_decoration(&shirabe_php_shim::to_string(&cell))); + } + + let column_width = *self.column_widths.get(&column).unwrap_or(&0); + cell_width = cell_width.max(column_width); + + if let Some(max) = self.column_max_widths.get(&column) { + (*max).min(cell_width) + } else { + cell_width + } + } + + /// Called after rendering to cleanup cache data. + fn cleanup(&mut self) { + self.effective_column_widths = IndexMap::new(); + self.number_of_columns = None; + } + + fn init_styles() -> IndexMap<String, TableStyle> { + let mut borderless = TableStyle::default(); + borderless + .set_horizontal_border_chars("=".to_string(), None) + .set_vertical_border_chars(" ".to_string(), None) + .set_default_crossing_char(" ".to_string()); + + let mut compact = TableStyle::default(); + compact + .set_horizontal_border_chars("".to_string(), None) + .set_vertical_border_chars("".to_string(), None) + .set_default_crossing_char("".to_string()) + .set_cell_row_content_format("%s ".to_string()); + + let mut style_guide = TableStyle::default(); + style_guide + .set_horizontal_border_chars("-".to_string(), None) + .set_vertical_border_chars(" ".to_string(), None) + .set_default_crossing_char(" ".to_string()) + .set_cell_header_format("%s".to_string()); + + let mut r#box = TableStyle::default(); + r#box + .set_horizontal_border_chars("─".to_string(), None) + .set_vertical_border_chars("│".to_string(), None) + .set_crossing_chars( + "┼".to_string(), + "┌".to_string(), + "┬".to_string(), + "┐".to_string(), + "┤".to_string(), + "┘".to_string(), + "┴".to_string(), + "└".to_string(), + "├".to_string(), + None, + None, + None, + ); + + let mut box_double = TableStyle::default(); + box_double + .set_horizontal_border_chars("═".to_string(), Some("─".to_string())) + .set_vertical_border_chars("║".to_string(), Some("│".to_string())) + .set_crossing_chars( + "┼".to_string(), + "╔".to_string(), + "╤".to_string(), + "╗".to_string(), + "╢".to_string(), + "╝".to_string(), + "╧".to_string(), + "╚".to_string(), + "╟".to_string(), + Some("╠".to_string()), + Some("╪".to_string()), + Some("╣".to_string()), + ); + + let mut result: IndexMap<String, TableStyle> = IndexMap::new(); + result.insert("default".to_string(), TableStyle::default()); + result.insert("borderless".to_string(), borderless); + result.insert("compact".to_string(), compact); + result.insert("symfony-style-guide".to_string(), style_guide); + result.insert("box".to_string(), r#box); + result.insert("box-double".to_string(), box_double); + + result + } + + fn resolve_style( + &self, + name: PhpMixed, + ) -> anyhow::Result<Result<TableStyle, InvalidArgumentException>> { + if shirabe_php_shim::instance_of::<TableStyle>(&name) { + // TODO(phase-b): extract the owned TableStyle out of PhpMixed. + todo!() + } + + let name_str = shirabe_php_shim::to_string(&name); + let styles_guard = styles().lock().unwrap(); + if let Some(_style) = styles_guard.as_ref().and_then(|s| s.get(&name_str)) { + // TODO(phase-b): TableStyle is not Clone; sharing semantics need resolving. + todo!() + } + + Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Style \"%s\" is not defined.", + &[PhpMixed::from(name_str)], + ), + code: 0, + }, + ))) + } + + // --- Phase A helpers for `mixed`/array semantics over PhpMixed ------------------------------- + // + // These bridge PHP's dynamic typing (a cell is a TableCell, a scalar, null, or an array; + // a row is an array or a TableSeparator) onto PhpMixed. Their bodies are deferred to Phase B + // where the concrete PhpMixed representation is settled. + + fn formatter_is_wrappable(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { + // PHP: $this->output->getFormatter() instanceof WrappableOutputFormatterInterface + // TODO(phase-b): trait-to-trait instanceof check requires concrete formatter knowledge. + let _ = std::any::type_name::<dyn WrappableOutputFormatterInterface>(); + todo!() + } + + /// PHP `Helper::removeDecoration($this->output->getFormatter(), $string)`. + fn remove_decoration(&self, string: &str) -> String { + let formatter = self.output.borrow().get_formatter(); + let mut formatter = formatter.borrow_mut(); + Helper::remove_decoration(&mut *formatter, string) + } + + fn output_is_console_section(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { + // PHP: $this->output instanceof ConsoleSectionOutput todo!() } - pub fn set_rows(&mut self, _rows: Vec<PhpMixed>) -> &mut Self { + fn is_divider(_row: &PhpMixed, _divider: &TableSeparator) -> bool { + // PHP: $divider === $row (object identity) todo!() } - pub fn add_row(&mut self, _row: PhpMixed) -> &mut Self { + fn cell_colspan(_cell: &PhpMixed) -> i64 { + // PHP: $cell->getColspan() todo!() } - pub fn render(&mut self) { + fn cell_rowspan(_cell: &PhpMixed) -> i64 { + // PHP: $cell->getRowspan() + todo!() + } + + fn cell_get_style(_cell: &PhpMixed) -> Option<TableCellStyle> { + // PHP: $cell->getStyle() + todo!() + } + + fn cell_style_is_table_cell_style(_cell: &PhpMixed) -> bool { + // PHP: $cell->getStyle() instanceof TableCellStyle + todo!() + } + + fn table_cell_options_colspan(_colspan: i64) -> IndexMap<String, TableCellOption> { + // PHP: ['colspan' => $colspan] + todo!() + } + + fn table_cell_options_colspan_style( + _colspan: i64, + _style: Option<TableCellStyle>, + ) -> IndexMap<String, TableCellOption> { + // PHP: ['colspan' => $colspan, 'style' => $style] + todo!() + } + + fn row_get(_row: &PhpMixed, _index: i64) -> Option<PhpMixed> { + // PHP: isset($row[$i]) ? $row[$i] : null, where $row is an array-typed PhpMixed + todo!() + } + + fn row_get_index(_row: &[PhpMixed], _index: i64) -> Option<PhpMixed> { + // PHP: $row[$column] ?? null, honoring sparse integer keys + todo!() + } + + fn array_set(_row: &mut [PhpMixed], _index: i64, _value: PhpMixed) { + // PHP: $row[$index] = $value (sparse assignment) todo!() } - pub fn set_style(&mut self, _style: &str) -> &mut Self { + fn vec_set(_row: &mut Vec<PhpMixed>, _index: i64, _value: PhpMixed) { + // PHP: $row[$index] = $value (sparse assignment) todo!() } - pub fn set_column_width(&mut self, _column_index: usize, _width: i64) -> &mut Self { + /// PHP rows/cells are integer-keyed arrays. We model a row as a positional + /// `Vec<PhpMixed>`. A cell that is a TableCell/TableSeparator cannot be carried by + /// PhpMixed (php-shim limitation), so such conversions are deferred. + fn to_row_vec(_row: PhpMixed) -> Vec<PhpMixed> { + // PHP: an array-typed value seen as a list of cells. todo!() } - pub fn set_column_widths(&mut self, _widths: Vec<i64>) -> &mut Self { + fn from_row_vec(_row: Vec<PhpMixed>) -> PhpMixed { + // PHP: a list of cells seen as an array-typed value. todo!() } - pub fn set_column_max_width(&mut self, _column_index: usize, _width: i64) -> &mut Self { + /// PhpMixed cannot hold console value objects (TableCell/TableSeparator). The cell + /// representation is deferred to a later phase. + fn table_cell_to_mixed(_cell: TableCell) -> PhpMixed { todo!() } - pub fn set_horizontal(&mut self, _horizontal: bool) -> &mut Self { + fn table_separator_to_mixed(_separator: TableSeparator) -> PhpMixed { todo!() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_cell.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell.rs new file mode 100644 index 0000000..d8d8339 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell.rs @@ -0,0 +1,113 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::table_cell_style::TableCellStyle; +use indexmap::IndexMap; +use std::rc::Rc; + +/// A `TableCell` option value: an integer span, a `TableCellStyle`, or null. +#[derive(Debug, Clone)] +pub enum TableCellOption { + Int(i64), + Style(Rc<TableCellStyle>), + Null, +} + +#[derive(Debug, Clone)] +pub struct TableCell { + pub(crate) value: String, + options: IndexMap<String, TableCellOption>, +} + +impl TableCell { + pub fn new( + value: &str, + options: IndexMap<String, TableCellOption>, + ) -> Result<Self, InvalidArgumentException> { + let mut this_options: IndexMap<String, TableCellOption> = IndexMap::new(); + this_options.insert("rowspan".to_string(), TableCellOption::Int(1)); + this_options.insert("colspan".to_string(), TableCellOption::Int(1)); + this_options.insert("style".to_string(), TableCellOption::Null); + + // check option names + let diff: Vec<String> = options + .keys() + .filter(|key| !this_options.contains_key(*key)) + .cloned() + .collect(); + if !diff.is_empty() { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The TableCell does not support the following options: '%s'.", + &[shirabe_php_shim::PhpMixed::String(diff.join("', '"))], + ), + code: 0, + }, + )); + } + + if let Some(style) = options.get("style") { + if !matches!(style, TableCellOption::Style(_)) + && !matches!(style, TableCellOption::Null) + { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "The style option must be an instance of \"TableCellStyle\"." + .to_string(), + code: 0, + }, + )); + } + } + + for (key, option) in options { + this_options.insert(key, option); + } + + Ok(Self { + value: value.to_string(), + options: this_options, + }) + } + + /// Two-argument constructor (`__construct(string $value, array $options)`). + /// + /// The options used by the Table helper are internally controlled, so a malformed-option + /// error here would be a programming bug rather than a recoverable condition. + pub fn new2(value: &str, options: IndexMap<String, TableCellOption>) -> Self { + Self::new(value, options).expect("TableCell options built internally are always valid") + } + + /// Returns the cell value. + pub fn to_string(&self) -> String { + self.value.clone() + } + + /// Gets number of colspan. + pub fn get_colspan(&self) -> i64 { + match self.options["colspan"] { + TableCellOption::Int(colspan) => colspan, + _ => 0, + } + } + + /// Gets number of rowspan. + pub fn get_rowspan(&self) -> i64 { + match self.options["rowspan"] { + TableCellOption::Int(rowspan) => rowspan, + _ => 0, + } + } + + pub fn get_style(&self) -> Option<Rc<TableCellStyle>> { + match &self.options["style"] { + TableCellOption::Style(style) => Some(style.clone()), + _ => None, + } + } +} + +impl std::fmt::Display for TableCell { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_cell_style.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell_style.rs new file mode 100644 index 0000000..9d41c45 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell_style.rs @@ -0,0 +1,126 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use indexmap::IndexMap; + +pub const DEFAULT_ALIGN: &str = "left"; + +const TAG_OPTIONS: [&str; 3] = ["fg", "bg", "options"]; + +/// Maps an alignment name to the corresponding `STR_PAD_*` value. +fn align_map(align: &str) -> Option<i64> { + match align { + "left" => Some(shirabe_php_shim::STR_PAD_RIGHT), + "center" => Some(shirabe_php_shim::STR_PAD_BOTH), + "right" => Some(shirabe_php_shim::STR_PAD_LEFT), + _ => None, + } +} + +fn align_map_keys() -> Vec<&'static str> { + vec!["left", "center", "right"] +} + +#[derive(Debug)] +pub struct TableCellStyle { + options: IndexMap<String, shirabe_php_shim::PhpMixed>, +} + +impl TableCellStyle { + pub fn new( + options: IndexMap<String, shirabe_php_shim::PhpMixed>, + ) -> Result<Self, InvalidArgumentException> { + let mut this_options: IndexMap<String, shirabe_php_shim::PhpMixed> = IndexMap::new(); + this_options.insert( + "fg".to_string(), + shirabe_php_shim::PhpMixed::String("default".to_string()), + ); + this_options.insert( + "bg".to_string(), + shirabe_php_shim::PhpMixed::String("default".to_string()), + ); + this_options.insert("options".to_string(), shirabe_php_shim::PhpMixed::Null); + this_options.insert( + "align".to_string(), + shirabe_php_shim::PhpMixed::String(DEFAULT_ALIGN.to_string()), + ); + this_options.insert("cellFormat".to_string(), shirabe_php_shim::PhpMixed::Null); + + let diff: Vec<String> = options + .keys() + .filter(|key| !this_options.contains_key(*key)) + .cloned() + .collect(); + if !diff.is_empty() { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The TableCellStyle does not support the following options: '%s'.", + &[shirabe_php_shim::PhpMixed::String(diff.join("', '"))], + ), + code: 0, + }, + )); + } + + if let Some(align) = options.get("align") { + let align = match align { + shirabe_php_shim::PhpMixed::String(align) => align.clone(), + _ => String::new(), + }; + if align_map(&align).is_none() { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Wrong align value. Value must be following: '%s'.", + &[shirabe_php_shim::PhpMixed::String( + align_map_keys().join("', '"), + )], + ), + code: 0, + }, + )); + } + } + + for (key, value) in options { + this_options.insert(key, value); + } + + Ok(Self { + options: this_options, + }) + } + + pub fn get_options(&self) -> IndexMap<String, shirabe_php_shim::PhpMixed> { + self.options.clone() + } + + /// Gets options we need for tag for example fg, bg. + /// + /// @return string[] + pub fn get_tag_options(&self) -> IndexMap<String, shirabe_php_shim::PhpMixed> { + let mut result: IndexMap<String, shirabe_php_shim::PhpMixed> = IndexMap::new(); + for (key, value) in self.get_options() { + if TAG_OPTIONS.contains(&key.as_str()) + && !matches!(self.options[&key], shirabe_php_shim::PhpMixed::Null) + { + result.insert(key, value); + } + } + result + } + + pub fn get_pad_by_align(&self) -> i64 { + let align = match &self.get_options()["align"] { + shirabe_php_shim::PhpMixed::String(align) => align.clone(), + _ => String::new(), + }; + align_map(&align).unwrap() + } + + pub fn get_cell_format(&self) -> Option<String> { + match &self.get_options()["cellFormat"] { + shirabe_php_shim::PhpMixed::String(format) => Some(format.clone()), + _ => None, + } + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_rows.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_rows.rs new file mode 100644 index 0000000..c8394df --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_rows.rs @@ -0,0 +1,43 @@ +use shirabe_php_shim::PhpMixed; + +/// @internal +/// +/// In PHP this wraps a `\Closure` yielding a `\Traversable` of row groups. The generator +/// borrows the Table to lazily call `fillCells()`. For the Rust port we precompute the row +/// groups eagerly (see `Table::build_table_rows`) and store them here. +#[derive(Debug)] +pub struct TableRows { + row_groups: Vec<Vec<PhpMixed>>, +} + +impl TableRows { + pub fn from_row_groups(row_groups: Vec<Vec<PhpMixed>>) -> Self { + Self { row_groups } + } + + pub fn get_iterator(&self) -> std::slice::Iter<'_, Vec<PhpMixed>> { + self.row_groups.iter() + } + + pub fn into_row_groups(self) -> Vec<Vec<PhpMixed>> { + self.row_groups + } +} + +impl<'a> IntoIterator for &'a TableRows { + type Item = &'a Vec<PhpMixed>; + type IntoIter = std::slice::Iter<'a, Vec<PhpMixed>>; + + fn into_iter(self) -> Self::IntoIter { + self.row_groups.iter() + } +} + +impl IntoIterator for TableRows { + type Item = Vec<PhpMixed>; + type IntoIter = std::vec::IntoIter<Vec<PhpMixed>>; + + fn into_iter(self) -> Self::IntoIter { + self.row_groups.into_iter() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs index 22ac013..9dac411 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs @@ -1,14 +1,29 @@ -#[derive(Debug)] -pub struct TableSeparator; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::table_cell::{TableCell, TableCellOption}; +use indexmap::IndexMap; -impl Default for TableSeparator { - fn default() -> Self { - Self::new() - } +/// Marks a row as being a separator. +#[derive(Debug, Clone)] +pub struct TableSeparator { + inner: TableCell, } impl TableSeparator { pub fn new() -> Self { - todo!() + Self::new1(IndexMap::new()).expect("TableSeparator default options are always valid") + } + + pub fn new1( + options: IndexMap<String, TableCellOption>, + ) -> Result<Self, InvalidArgumentException> { + Ok(Self { + inner: TableCell::new("", options)?, + }) + } +} + +impl Default for TableSeparator { + fn default() -> Self { + Self::new() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_style.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_style.rs new file mode 100644 index 0000000..5820f15 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_style.rs @@ -0,0 +1,293 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; + +/// Defines the styles for a Table. +#[derive(Debug)] +pub struct TableStyle { + padding_char: String, + horizontal_outside_border_char: String, + horizontal_inside_border_char: String, + vertical_outside_border_char: String, + vertical_inside_border_char: String, + crossing_char: String, + crossing_top_right_char: String, + crossing_top_mid_char: String, + crossing_top_left_char: String, + crossing_mid_right_char: String, + crossing_bottom_right_char: String, + crossing_bottom_mid_char: String, + crossing_bottom_left_char: String, + crossing_mid_left_char: String, + crossing_top_left_bottom_char: String, + crossing_top_mid_bottom_char: String, + crossing_top_right_bottom_char: String, + header_title_format: String, + footer_title_format: String, + cell_header_format: String, + cell_row_format: String, + cell_row_content_format: String, + border_format: String, + pad_type: i64, +} + +impl Default for TableStyle { + fn default() -> Self { + Self { + padding_char: " ".to_string(), + horizontal_outside_border_char: "-".to_string(), + horizontal_inside_border_char: "-".to_string(), + vertical_outside_border_char: "|".to_string(), + vertical_inside_border_char: "|".to_string(), + crossing_char: "+".to_string(), + crossing_top_right_char: "+".to_string(), + crossing_top_mid_char: "+".to_string(), + crossing_top_left_char: "+".to_string(), + crossing_mid_right_char: "+".to_string(), + crossing_bottom_right_char: "+".to_string(), + crossing_bottom_mid_char: "+".to_string(), + crossing_bottom_left_char: "+".to_string(), + crossing_mid_left_char: "+".to_string(), + crossing_top_left_bottom_char: "+".to_string(), + crossing_top_mid_bottom_char: "+".to_string(), + crossing_top_right_bottom_char: "+".to_string(), + header_title_format: "<fg=black;bg=white;options=bold> %s </>".to_string(), + footer_title_format: "<fg=black;bg=white;options=bold> %s </>".to_string(), + cell_header_format: "<info>%s</info>".to_string(), + cell_row_format: "%s".to_string(), + cell_row_content_format: " %s ".to_string(), + border_format: "%s".to_string(), + pad_type: shirabe_php_shim::STR_PAD_RIGHT, + } + } +} + +impl TableStyle { + /// Sets padding character, used for cell padding. + pub fn set_padding_char( + &mut self, + padding_char: String, + ) -> anyhow::Result<Result<&mut Self, LogicException>> { + if padding_char.is_empty() { + return Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: "The padding char must not be empty.".to_string(), + code: 0, + }))); + } + + self.padding_char = padding_char; + + Ok(Ok(self)) + } + + /// Gets padding character, used for cell padding. + pub fn get_padding_char(&self) -> String { + self.padding_char.clone() + } + + /// Sets horizontal border characters. + pub fn set_horizontal_border_chars( + &mut self, + outside: String, + inside: Option<String>, + ) -> &mut Self { + self.horizontal_outside_border_char = outside.clone(); + self.horizontal_inside_border_char = inside.unwrap_or(outside); + + self + } + + /// Sets vertical border characters. + pub fn set_vertical_border_chars( + &mut self, + outside: String, + inside: Option<String>, + ) -> &mut Self { + self.vertical_outside_border_char = outside.clone(); + self.vertical_inside_border_char = inside.unwrap_or(outside); + + self + } + + /// Gets border characters. + pub fn get_border_chars(&self) -> Vec<String> { + vec![ + self.horizontal_outside_border_char.clone(), + self.vertical_outside_border_char.clone(), + self.horizontal_inside_border_char.clone(), + self.vertical_inside_border_char.clone(), + ] + } + + /// Sets crossing characters. + #[allow(clippy::too_many_arguments)] + pub fn set_crossing_chars( + &mut self, + cross: String, + top_left: String, + top_mid: String, + top_right: String, + mid_right: String, + bottom_right: String, + bottom_mid: String, + bottom_left: String, + mid_left: String, + top_left_bottom: Option<String>, + top_mid_bottom: Option<String>, + top_right_bottom: Option<String>, + ) -> &mut Self { + self.crossing_char = cross.clone(); + self.crossing_top_left_char = top_left; + self.crossing_top_mid_char = top_mid; + self.crossing_top_right_char = top_right; + self.crossing_mid_right_char = mid_right.clone(); + self.crossing_bottom_right_char = bottom_right; + self.crossing_bottom_mid_char = bottom_mid; + self.crossing_bottom_left_char = bottom_left; + self.crossing_mid_left_char = mid_left.clone(); + self.crossing_top_left_bottom_char = top_left_bottom.unwrap_or(mid_left); + self.crossing_top_mid_bottom_char = top_mid_bottom.unwrap_or(cross); + self.crossing_top_right_bottom_char = top_right_bottom.unwrap_or(mid_right); + + self + } + + /// Sets default crossing character used for each cross. + pub fn set_default_crossing_char(&mut self, char: String) -> &mut Self { + self.set_crossing_chars( + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char, + None, + None, + None, + ) + } + + /// Gets crossing character. + pub fn get_crossing_char(&self) -> String { + self.crossing_char.clone() + } + + /// Gets crossing characters. + pub fn get_crossing_chars(&self) -> Vec<String> { + vec![ + self.crossing_char.clone(), + self.crossing_top_left_char.clone(), + self.crossing_top_mid_char.clone(), + self.crossing_top_right_char.clone(), + self.crossing_mid_right_char.clone(), + self.crossing_bottom_right_char.clone(), + self.crossing_bottom_mid_char.clone(), + self.crossing_bottom_left_char.clone(), + self.crossing_mid_left_char.clone(), + self.crossing_top_left_bottom_char.clone(), + self.crossing_top_mid_bottom_char.clone(), + self.crossing_top_right_bottom_char.clone(), + ] + } + + /// Sets header cell format. + pub fn set_cell_header_format(&mut self, cell_header_format: String) -> &mut Self { + self.cell_header_format = cell_header_format; + + self + } + + /// Gets header cell format. + pub fn get_cell_header_format(&self) -> String { + self.cell_header_format.clone() + } + + /// Sets row cell format. + pub fn set_cell_row_format(&mut self, cell_row_format: String) -> &mut Self { + self.cell_row_format = cell_row_format; + + self + } + + /// Gets row cell format. + pub fn get_cell_row_format(&self) -> String { + self.cell_row_format.clone() + } + + /// Sets row cell content format. + pub fn set_cell_row_content_format(&mut self, cell_row_content_format: String) -> &mut Self { + self.cell_row_content_format = cell_row_content_format; + + self + } + + /// Gets row cell content format. + pub fn get_cell_row_content_format(&self) -> String { + self.cell_row_content_format.clone() + } + + /// Sets table border format. + pub fn set_border_format(&mut self, border_format: String) -> &mut Self { + self.border_format = border_format; + + self + } + + /// Gets table border format. + pub fn get_border_format(&self) -> String { + self.border_format.clone() + } + + /// Sets cell padding type. + pub fn set_pad_type( + &mut self, + pad_type: i64, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + if ![ + shirabe_php_shim::STR_PAD_LEFT, + shirabe_php_shim::STR_PAD_RIGHT, + shirabe_php_shim::STR_PAD_BOTH, + ] + .contains(&pad_type) + { + return Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "Invalid padding type. Expected one of (STR_PAD_LEFT, STR_PAD_RIGHT, STR_PAD_BOTH)." + .to_string(), + code: 0, + }, + ))); + } + + self.pad_type = pad_type; + + Ok(Ok(self)) + } + + /// Gets cell padding type. + pub fn get_pad_type(&self) -> i64 { + self.pad_type + } + + pub fn get_header_title_format(&self) -> String { + self.header_title_format.clone() + } + + pub fn set_header_title_format(&mut self, format: String) -> &mut Self { + self.header_title_format = format; + + self + } + + pub fn get_footer_title_format(&self) -> String { + self.footer_title_format.clone() + } + + pub fn set_footer_title_format(&mut self, format: String) -> &mut Self { + self.footer_title_format = format; + + self + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs new file mode 100644 index 0000000..e9cb8d3 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs @@ -0,0 +1,643 @@ +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::input::input::Input; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::streamable_input_interface::StreamableInputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// ArgvInput represents an input coming from the CLI arguments. +/// +/// Usage: +/// +/// $input = new ArgvInput(); +/// +/// By default, the `$_SERVER['argv']` array is used for the input values. +/// +/// This can be overridden by explicitly passing the input values in the constructor: +/// +/// $input = new ArgvInput($_SERVER['argv']); +/// +/// If you pass it yourself, don't forget that the first element of the array +/// is the name of the running application. +/// +/// When passing an argument to the constructor, be sure that it respects +/// the same rules as the argv one. It's almost always better to use the +/// `StringInput` when you want to provide your own input. +#[derive(Debug)] +pub struct ArgvInput { + pub(crate) inner: Input, + tokens: Vec<String>, + parsed: Vec<String>, +} + +impl ArgvInput { + pub fn new( + argv: Option<Vec<String>>, + definition: Option<InputDefinition>, + ) -> anyhow::Result<Self> { + // $argv = $argv ?? $_SERVER['argv'] ?? []; + let mut argv = match argv { + Some(argv) => argv, + None => std::env::args().collect(), + }; + + // strip the application name + if !argv.is_empty() { + argv.remove(0); + } + + let mut input = ArgvInput { + inner: Input::new(None)?, + tokens: argv, + parsed: vec![], + }; + + // parent::__construct($definition) + match definition { + None => {} + Some(definition) => { + input.bind(&definition)?; + input.inner.validate()?; + } + } + + Ok(input) + } + + pub(crate) fn set_tokens(&mut self, tokens: Vec<String>) { + self.tokens = tokens; + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.inner.arguments = IndexMap::new(); + self.inner.options = IndexMap::new(); + self.inner.definition = definition.clone(); + + self.parse()?; + + Ok(()) + } + + fn parse(&mut self) -> anyhow::Result<()> { + let mut parse_options = true; + self.parsed = self.tokens.clone(); + while !self.parsed.is_empty() { + let token = self.parsed.remove(0); + parse_options = self.parse_token(&token, parse_options)?; + } + Ok(()) + } + + pub(crate) fn parse_token(&mut self, token: &str, parse_options: bool) -> anyhow::Result<bool> { + if parse_options && token == "" { + self.parse_argument(token)?; + } else if parse_options && token == "--" { + return Ok(false); + } else if parse_options && shirabe_php_shim::str_starts_with(token, "--") { + self.parse_long_option(token)?; + } else if parse_options && token.as_bytes().first() == Some(&b'-') && token != "-" { + self.parse_short_option(token)?; + } else { + self.parse_argument(token)?; + } + + Ok(parse_options) + } + + /// Parses a short option. + fn parse_short_option(&mut self, token: &str) -> anyhow::Result<()> { + let name = shirabe_php_shim::substr(token, 1, None); + + if shirabe_php_shim::strlen(&name) > 1 { + let first = shirabe_php_shim::substr(&name, 0, Some(1)); + if self.inner.definition.has_shortcut(&first) + && self + .inner + .definition + .get_option_for_shortcut(&first)? + .accept_value() + { + // an option with a value (with no space) + self.add_short_option( + &first, + PhpMixed::String(shirabe_php_shim::substr(&name, 1, None)), + )?; + } else { + self.parse_short_option_set(&name)?; + } + } else { + self.add_short_option(&name, PhpMixed::Null)?; + } + + Ok(()) + } + + /// Parses a short option set. + fn parse_short_option_set(&mut self, name: &str) -> anyhow::Result<()> { + let len = shirabe_php_shim::strlen(name); + let mut i = 0; + while i < len { + let name_i = shirabe_php_shim::substr(name, i, Some(1)); + if !self.inner.definition.has_shortcut(&name_i) { + let encoding = shirabe_php_shim::mb_detect_encoding(name, None, true); + let bad = match encoding { + None => name_i.clone(), + Some(encoding) => { + shirabe_php_shim::mb_substr(name, i, Some(1), Some(&encoding)) + } + }; + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(bad)], + ), + code: 0, + }) + .into()); + } + + let option = self.inner.definition.get_option_for_shortcut(&name_i)?; + if option.accept_value() { + let value = if i == len - 1 { + PhpMixed::Null + } else { + PhpMixed::String(shirabe_php_shim::substr(name, i + 1, None)) + }; + self.add_long_option(&option.get_name().to_string(), value)?; + + break; + } else { + self.add_long_option(&option.get_name().to_string(), PhpMixed::Null)?; + } + i += 1; + } + + Ok(()) + } + + /// Parses a long option. + fn parse_long_option(&mut self, token: &str) -> anyhow::Result<()> { + let name = shirabe_php_shim::substr(token, 2, None); + + match shirabe_php_shim::strpos(&name, "=") { + Some(pos) => { + let pos = pos as i64; + let value = shirabe_php_shim::substr(&name, pos + 1, None); + if value == "" { + self.parsed.insert(0, value.clone()); + } + self.add_long_option( + &shirabe_php_shim::substr(&name, 0, Some(pos)), + PhpMixed::String(value), + )?; + } + None => { + self.add_long_option(&name, PhpMixed::Null)?; + } + } + + Ok(()) + } + + /// Parses an argument. + fn parse_argument(&mut self, token: &str) -> anyhow::Result<()> { + let c = self.inner.arguments.len() as i64; + + // if input is expecting another argument, add it + if self.inner.definition.has_argument(&PhpMixed::Int(c)) { + let arg = self.inner.definition.get_argument(&PhpMixed::Int(c))?; + let value = if arg.is_array() { + PhpMixed::List(vec![Box::new(PhpMixed::String(token.to_string()))]) + } else { + PhpMixed::String(token.to_string()) + }; + self.inner + .arguments + .insert(arg.get_name().to_string(), value); + + // if last argument isArray(), append token to last argument + } else if self.inner.definition.has_argument(&PhpMixed::Int(c - 1)) + && self + .inner + .definition + .get_argument(&PhpMixed::Int(c - 1))? + .is_array() + { + let arg = self.inner.definition.get_argument(&PhpMixed::Int(c - 1))?; + if let Some(PhpMixed::List(list)) = self.inner.arguments.get_mut(arg.get_name()) { + list.push(Box::new(PhpMixed::String(token.to_string()))); + } + + // unexpected argument + } else { + let mut all = self.inner.definition.get_arguments().clone(); + let mut symfony_command_name: Option<PhpMixed> = None; + let first_key = all.keys().next().cloned(); + if let Some(key) = &first_key { + let input_argument = &all[key]; + if input_argument.get_name() == "command" { + symfony_command_name = match self.inner.arguments.get("command") { + Some(v) => Some(v.clone()), + None => None, + }; + all.shift_remove(key); + } + } + + let message = if all.len() > 0 { + let names: Vec<String> = all.keys().cloned().collect(); + match &symfony_command_name { + Some(symfony_command_name) + if !matches!(symfony_command_name, PhpMixed::Null) => + { + shirabe_php_shim::sprintf( + "Too many arguments to \"%s\" command, expected arguments \"%s\".", + &[ + symfony_command_name.clone(), + PhpMixed::String(shirabe_php_shim::implode("\" \"", &names)), + ], + ) + } + _ => shirabe_php_shim::sprintf( + "Too many arguments, expected arguments \"%s\".", + &[PhpMixed::String(shirabe_php_shim::implode("\" \"", &names))], + ), + } + } else if symfony_command_name + .as_ref() + .map(|n| !matches!(n, PhpMixed::Null)) + .unwrap_or(false) + { + shirabe_php_shim::sprintf( + "No arguments expected for \"%s\" command, got \"%s\".", + &[ + symfony_command_name.clone().unwrap(), + PhpMixed::String(token.to_string()), + ], + ) + } else { + shirabe_php_shim::sprintf( + "No arguments expected, got \"%s\".", + &[PhpMixed::String(token.to_string())], + ) + }; + + return Err( + RuntimeException(shirabe_php_shim::RuntimeException { message, code: 0 }).into(), + ); + } + + Ok(()) + } + + /// Adds a short option value. + fn add_short_option(&mut self, shortcut: &str, value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_shortcut(shortcut) { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(shortcut.to_string())], + ), + code: 0, + }) + .into()); + } + + self.add_long_option( + &self + .inner + .definition + .get_option_for_shortcut(shortcut)? + .get_name() + .to_string(), + value, + ) + } + + /// Adds a long option value. + fn add_long_option(&mut self, name: &str, mut value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_option(name) { + if !self.inner.definition.has_negation(name) { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + + let option_name = self.inner.definition.negation_to_name(name)?; + if !matches!(value, PhpMixed::Null) { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not accept a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + self.inner + .options + .insert(option_name, PhpMixed::Bool(false)); + + return Ok(()); + } + + let option = self.inner.definition.get_option(name)?; + + if !matches!(value, PhpMixed::Null) && !option.accept_value() { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not accept a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + + // in_array($value, ['', null], true) + let value_is_empty_or_null = matches!(&value, PhpMixed::String(s) if s.is_empty()) + || matches!(value, PhpMixed::Null); + if value_is_empty_or_null && option.accept_value() && !self.parsed.is_empty() { + // if option accepts an optional or mandatory argument + // let's see if there is one provided + let next = self.parsed.remove(0); + // (isset($next[0]) && '-' !== $next[0]) || in_array($next, ['', null], true) + let next_first = next.as_bytes().first().copied(); + if (next_first.is_some() && next_first != Some(b'-')) || next.is_empty() { + value = PhpMixed::String(next); + } else { + self.parsed.insert(0, next); + } + } + + if matches!(value, PhpMixed::Null) { + if option.is_value_required() { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option requires a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + + if !option.is_array() && !option.is_value_optional() { + value = PhpMixed::Bool(true); + } + } + + if option.is_array() { + match self.inner.options.get_mut(name) { + Some(PhpMixed::List(list)) => { + list.push(Box::new(value)); + } + _ => { + self.inner + .options + .insert(name.to_string(), PhpMixed::List(vec![Box::new(value)])); + } + } + } else { + self.inner.options.insert(name.to_string(), value); + } + + Ok(()) + } + + pub fn get_first_argument(&self) -> Option<String> { + let mut is_option = false; + for (i, token) in self.tokens.iter().enumerate() { + if !token.is_empty() && token.as_bytes()[0] == b'-' { + if shirabe_php_shim::str_contains(token, "=") || self.tokens.get(i + 1).is_none() { + continue; + } + + // If it's a long option, consider that everything after "--" is the option name. + // Otherwise, use the last char (if it's a short option set, only the last one can take a value with space separator) + let mut name = if token.as_bytes().get(1) == Some(&b'-') { + shirabe_php_shim::substr(token, 2, None) + } else { + shirabe_php_shim::substr(token, -1, None) + }; + if !self.inner.options.contains_key(&name) + && !self.inner.definition.has_shortcut(&name) + { + // noop + } else { + if !self.inner.options.contains_key(&name) { + if let Ok(resolved) = self.inner.definition.shortcut_to_name(&name) { + name = resolved; + } + } + if let Some(option_value) = self.inner.options.get(&name) { + if self.tokens.get(i + 1).map(|t| t.as_str()) == option_value.as_string() { + is_option = true; + } + } + } + + continue; + } + + if is_option { + is_option = false; + continue; + } + + return Some(token.clone()); + } + + None + } + + pub fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + let values = to_array(values); + + for token in &self.tokens { + if only_params && token == "--" { + return false; + } + for value in &values { + // Options with values: + // For long options, test for '--option=' at beginning + // For short options, test for '-o' at beginning + let leading = if shirabe_php_shim::str_starts_with(value, "--") { + format!("{}=", value) + } else { + value.clone() + }; + if token == value + || (!leading.is_empty() && shirabe_php_shim::str_starts_with(token, &leading)) + { + return true; + } + } + } + + false + } + + pub fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + let values = to_array(values); + let mut tokens = self.tokens.clone(); + + while !tokens.is_empty() { + let token = tokens.remove(0); + if only_params && token == "--" { + return default; + } + + for value in &values { + if &token == value { + return match tokens.first() { + Some(_) => PhpMixed::String(tokens.remove(0)), + None => PhpMixed::Null, + }; + } + // Options with values: + // For long options, test for '--option=' at beginning + // For short options, test for '-o' at beginning + let leading = if shirabe_php_shim::str_starts_with(value, "--") { + format!("{}=", value) + } else { + value.clone() + }; + if !leading.is_empty() && shirabe_php_shim::str_starts_with(&token, &leading) { + return PhpMixed::String(shirabe_php_shim::substr( + &token, + shirabe_php_shim::strlen(&leading), + None, + )); + } + } + } + + default + } + + /// Returns a stringified representation of the args passed to the command. + pub fn to_string(&self) -> String { + let tokens: Vec<String> = self + .tokens + .iter() + .map(|token| { + if let Some(m) = shirabe_php_shim::preg_match_groups("{^(-[^=]+=)(.+)}", token) { + return format!("{}{}", m[1], self.inner.escape_token(&m[2])); + } + + if !token.is_empty() && token.as_bytes()[0] != b'-' { + return self.inner.escape_token(token); + } + + token.clone() + }) + .collect(); + + shirabe_php_shim::implode(" ", &tokens) + } +} + +impl InputInterface for ArgvInput { + fn get_first_argument(&self) -> Option<String> { + ArgvInput::get_first_argument(self) + } + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + ArgvInput::has_parameter_option(self, values, only_params) + } + + fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + ArgvInput::get_parameter_option(self, values, default, only_params) + } + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + ArgvInput::bind(self, definition) + } + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() + } + + fn get_arguments(&self) -> IndexMap<String, PhpMixed> { + self.inner.get_arguments() + } + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_argument(name) + } + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_argument(name, value) + } + + fn has_argument(&self, name: &str) -> bool { + self.inner.has_argument(name) + } + + fn get_options(&self) -> IndexMap<String, PhpMixed> { + self.inner.get_options() + } + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_option(name) + } + + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_option(name, value) + } + + fn has_option(&self, name: &str) -> bool { + self.inner.has_option(name) + } + + fn is_interactive(&self) -> bool { + self.inner.is_interactive() + } + + fn set_interactive(&mut self, interactive: bool) { + self.inner.set_interactive(interactive) + } +} + +impl StreamableInputInterface for ArgvInput { + fn set_stream(&mut self, stream: PhpMixed) { + self.inner.set_stream(stream) + } + + fn get_stream(&self) -> Option<PhpMixed> { + self.inner.get_stream() + } +} + +/// PHP `(array) $values` cast: a string becomes a single-element array. +fn to_array(values: PhpMixed) -> Vec<String> { + match values { + PhpMixed::List(list) => list + .into_iter() + .map(|v| shirabe_php_shim::php_to_string(&v)) + .collect(), + PhpMixed::Array(array) => array + .into_iter() + .map(|(_, v)| shirabe_php_shim::php_to_string(&v)) + .collect(), + PhpMixed::Null => vec![], + other => vec![shirabe_php_shim::php_to_string(&other)], + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs index 0c68cf2..17c8aa8 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs @@ -1,69 +1,378 @@ -use crate::symfony::console::input::InputDefinition; -use crate::symfony::console::input::InputInterface; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::invalid_option_exception::InvalidOptionException; +use crate::symfony::console::input::input::Input; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +/// ArrayInput represents an input provided as an array. +/// +/// Usage: +/// +/// $input = new ArrayInput(['command' => 'foo:bar', 'foo' => 'bar', '--bar' => 'foobar']); +/// +/// PHP arrays can mix integer and string keys; `parameters` preserves both the +/// key type (`PhpMixed::Int` / `PhpMixed::String`) and the insertion order. #[derive(Debug)] -pub struct ArrayInput; +pub struct ArrayInput { + pub(crate) inner: Input, + parameters: Vec<(PhpMixed, PhpMixed)>, +} impl ArrayInput { pub fn new( - _parameters: IndexMap<String, PhpMixed>, - _definition: Option<InputDefinition>, - ) -> Self { - todo!() + parameters: Vec<(PhpMixed, PhpMixed)>, + definition: Option<InputDefinition>, + ) -> anyhow::Result<Self> { + let mut array_input = ArrayInput { + inner: Input::new(None)?, + parameters, + }; + + // parent::__construct($definition) + match definition { + None => {} + Some(definition) => { + array_input.bind(&definition)?; + array_input.inner.validate()?; + } + } + + Ok(array_input) + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.inner.arguments = IndexMap::new(); + self.inner.options = IndexMap::new(); + self.inner.definition = definition.clone(); + + self.parse()?; + + Ok(()) + } + + pub fn get_first_argument(&self) -> Option<PhpMixed> { + for (param, value) in &self.parameters { + // $param && \is_string($param) && '-' === $param[0] + if let PhpMixed::String(param) = param { + if !param.is_empty() && param.as_bytes()[0] == b'-' { + continue; + } + } + + return Some(value.clone()); + } + + None + } + + pub fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + let values = to_array(values); + + for (k, v) in &self.parameters { + // if (!\is_int($k)) { $v = $k; } + let v: PhpMixed = match k { + PhpMixed::Int(_) => v.clone(), + _ => k.clone(), + }; + + if only_params && matches!(&v, PhpMixed::String(s) if s == "--") { + return false; + } + + if values.iter().any(|x| x == &v) { + return true; + } + } + + false + } + + pub fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + let values = to_array(values); + + for (k, v) in &self.parameters { + // $onlyParams && ('--' === $k || (\is_int($k) && '--' === $v)) + if only_params { + let k_is_double_dash = matches!(k, PhpMixed::String(s) if s == "--"); + let int_v_double_dash = + matches!(k, PhpMixed::Int(_)) && matches!(v, PhpMixed::String(s) if s == "--"); + if k_is_double_dash || int_v_double_dash { + return default; + } + } + + match k { + PhpMixed::Int(_) => { + if values.iter().any(|x| x == v) { + return PhpMixed::Bool(true); + } + } + _ => { + if values.iter().any(|x| x == k) { + return v.clone(); + } + } + } + } + + default + } + + /// Returns a stringified representation of the args passed to the command. + pub fn to_string(&self) -> String { + let mut params: Vec<String> = vec![]; + for (param, val) in &self.parameters { + // $param && \is_string($param) && '-' === $param[0] + let is_option_key = + matches!(param, PhpMixed::String(s) if !s.is_empty() && s.as_bytes()[0] == b'-'); + if is_option_key { + let param = param.as_string().unwrap(); + let glue = if param.as_bytes().get(1) == Some(&b'-') { + "=" + } else { + " " + }; + if let PhpMixed::List(list) = val { + for v in list { + let v = shirabe_php_shim::php_to_string(v); + params.push(format!( + "{}{}", + param, + if v != "" { + format!("{}{}", glue, self.inner.escape_token(&v)) + } else { + String::new() + } + )); + } + } else { + let val = shirabe_php_shim::php_to_string(val); + params.push(format!( + "{}{}", + param, + if val != "" { + format!("{}{}", glue, self.inner.escape_token(&val)) + } else { + String::new() + } + )); + } + } else if let PhpMixed::List(list) = val { + let escaped: Vec<String> = list + .iter() + .map(|v| self.inner.escape_token(&shirabe_php_shim::php_to_string(v))) + .collect(); + params.push(shirabe_php_shim::implode(" ", &escaped)); + } else { + params.push( + self.inner + .escape_token(&shirabe_php_shim::php_to_string(val)), + ); + } + } + + shirabe_php_shim::implode(" ", ¶ms) + } + + fn parse(&mut self) -> anyhow::Result<()> { + // Clone to avoid borrowing self while mutating; PHP iterates over a copy semantically. + let parameters = self.parameters.clone(); + for (key, value) in parameters { + let key = shirabe_php_shim::php_to_string(&key); + if key == "--" { + return Ok(()); + } + if shirabe_php_shim::str_starts_with(&key, "--") { + self.add_long_option(&shirabe_php_shim::substr(&key, 2, None), value)?; + } else if shirabe_php_shim::str_starts_with(&key, "-") { + self.add_short_option(&shirabe_php_shim::substr(&key, 1, None), value)?; + } else { + self.add_argument(&PhpMixed::String(key), value)?; + } + } + + Ok(()) + } + + /// Adds a short option value. + fn add_short_option(&mut self, shortcut: &str, value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_shortcut(shortcut) { + return Err(InvalidOptionException(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(shortcut.to_string())], + ), + code: 0, + }, + )) + .into()); + } + + self.add_long_option( + &self + .inner + .definition + .get_option_for_shortcut(shortcut)? + .get_name() + .to_string(), + value, + ) + } + + /// Adds a long option value. + fn add_long_option(&mut self, name: &str, mut value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_option(name) { + if !self.inner.definition.has_negation(name) { + return Err(InvalidOptionException(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }, + )) + .into()); + } + + let option_name = self.inner.definition.negation_to_name(name)?; + self.inner + .options + .insert(option_name, PhpMixed::Bool(false)); + + return Ok(()); + } + + let option = self.inner.definition.get_option(name)?; + + if matches!(value, PhpMixed::Null) { + if option.is_value_required() { + return Err(InvalidOptionException(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option requires a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }, + )) + .into()); + } + + if !option.is_value_optional() { + value = PhpMixed::Bool(true); + } + } + + self.inner.options.insert(name.to_string(), value); + + Ok(()) + } + + /// Adds an argument value. + fn add_argument(&mut self, name: &PhpMixed, value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_argument(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[name.clone()], + ), + code: 0, + }) + .into(), + ); + } + + self.inner + .arguments + .insert(shirabe_php_shim::php_to_string(name), value); + + Ok(()) } } impl InputInterface for ArrayInput { fn get_first_argument(&self) -> Option<String> { - todo!() + ArrayInput::get_first_argument(self).map(|v| shirabe_php_shim::php_to_string(&v)) } - fn has_parameter_option(&self, _values: &[&str], _only_params: bool) -> bool { - todo!() + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + ArrayInput::has_parameter_option(self, values, only_params) } + fn get_parameter_option( &self, - _values: &[&str], - _default: PhpMixed, - _only_params: bool, + values: PhpMixed, + default: PhpMixed, + only_params: bool, ) -> PhpMixed { - todo!() + ArrayInput::get_parameter_option(self, values, default, only_params) } - fn bind(&mut self, _definition: &InputDefinition) -> anyhow::Result<()> { - todo!() + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + ArrayInput::bind(self, definition) } - fn validate(&self) -> anyhow::Result<()> { - todo!() + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() } + fn get_arguments(&self) -> IndexMap<String, PhpMixed> { - todo!() + self.inner.get_arguments() } - fn get_argument(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_argument(name) } - fn set_argument(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_argument(name, value) } - fn has_argument(&self, _name: &str) -> bool { - todo!() + + fn has_argument(&self, name: &str) -> bool { + self.inner.has_argument(name) } + fn get_options(&self) -> IndexMap<String, PhpMixed> { - todo!() + self.inner.get_options() } - fn get_option(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_option(name) } - fn set_option(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_option(name, value) } - fn has_option(&self, _name: &str) -> bool { - todo!() + + fn has_option(&self, name: &str) -> bool { + self.inner.has_option(name) } + fn is_interactive(&self) -> bool { - todo!() + self.inner.is_interactive() } - fn set_interactive(&mut self, _interactive: bool) { - todo!() + + fn set_interactive(&mut self, interactive: bool) { + self.inner.set_interactive(interactive) + } +} + +/// PHP `(array) $values` cast: a string becomes a single-element array. +fn to_array(values: PhpMixed) -> Vec<PhpMixed> { + match values { + PhpMixed::List(list) => list.into_iter().map(|v| *v).collect(), + PhpMixed::Array(array) => array.into_iter().map(|(_, v)| *v).collect(), + PhpMixed::Null => vec![], + other => vec![other], } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input.rs b/crates/shirabe-external-packages/src/symfony/console/input/input.rs new file mode 100644 index 0000000..4c878bc --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/input/input.rs @@ -0,0 +1,252 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::input::input_definition::InputDefinition; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// Input is the base class for all concrete Input classes. +/// +/// Three concrete classes are provided by default: +/// +/// * `ArgvInput`: The input comes from the CLI arguments (argv) +/// * `StringInput`: The input is provided as a string +/// * `ArrayInput`: The input is provided as an array +#[derive(Debug)] +pub struct Input { + pub(crate) definition: InputDefinition, + pub(crate) stream: PhpMixed, + pub(crate) options: IndexMap<String, PhpMixed>, + pub(crate) arguments: IndexMap<String, PhpMixed>, + pub(crate) interactive: bool, +} + +impl Input { + pub fn new(definition: Option<InputDefinition>) -> anyhow::Result<Self> { + let mut input = Input { + definition: InputDefinition::new(vec![])?, + stream: PhpMixed::Null, + options: IndexMap::new(), + arguments: IndexMap::new(), + interactive: true, + }; + + match definition { + None => { + input.definition = InputDefinition::new(vec![])?; + } + Some(definition) => { + input.bind(&definition)?; + input.validate()?; + } + } + + Ok(input) + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.arguments = IndexMap::new(); + self.options = IndexMap::new(); + self.definition = definition.clone(); + + self.parse()?; + + Ok(()) + } + + /// Processes command line arguments. + /// + /// This is abstract in PHP; concrete subclasses provide their own `parse`. + /// Since `Input` is embedded via `inner` in the subclasses, the subclass + /// drives the parsing instead. + fn parse(&mut self) -> anyhow::Result<()> { + unreachable!("Input::parse is abstract and overridden by subclasses") + } + + pub fn validate(&mut self) -> anyhow::Result<()> { + let definition = &self.definition; + let given_arguments = &self.arguments; + + let missing_arguments: Vec<String> = shirabe_php_shim::array_filter( + &shirabe_php_shim::array_keys(definition.get_arguments()), + |argument: &String| { + !given_arguments.contains_key(argument) + && definition + .get_argument(&PhpMixed::String(argument.clone())) + .map(|a| a.is_required()) + .unwrap_or(false) + }, + ); + + if missing_arguments.len() > 0 { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "Not enough arguments (missing: \"%s\").", + &[PhpMixed::String(shirabe_php_shim::implode( + ", ", + &missing_arguments, + ))], + ), + code: 0, + }) + .into()); + } + + Ok(()) + } + + pub fn is_interactive(&self) -> bool { + self.interactive + } + + pub fn set_interactive(&mut self, interactive: bool) { + self.interactive = interactive; + } + + pub fn get_arguments(&self) -> IndexMap<String, PhpMixed> { + shirabe_php_shim::array_merge_map( + self.definition.get_argument_defaults(), + self.arguments.clone(), + ) + } + + pub fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + if !self + .definition + .has_argument(&PhpMixed::String(name.to_string())) + { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + Ok(match self.arguments.get(name) { + Some(value) => value.clone(), + None => self + .definition + .get_argument(&PhpMixed::String(name.to_string()))? + .get_default() + .clone(), + }) + } + + pub fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + if !self + .definition + .has_argument(&PhpMixed::String(name.to_string())) + { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + self.arguments.insert(name.to_string(), value); + + Ok(()) + } + + pub fn has_argument(&self, name: &str) -> bool { + self.definition + .has_argument(&PhpMixed::String(name.to_string())) + } + + pub fn get_options(&self) -> IndexMap<String, PhpMixed> { + shirabe_php_shim::array_merge_map( + self.definition.get_option_defaults(), + self.options.clone(), + ) + } + + pub fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + if self.definition.has_negation(name) { + let value = self.get_option(&self.definition.negation_to_name(name)?)?; + if matches!(value, PhpMixed::Null) { + return Ok(value); + } + + return Ok(PhpMixed::Bool(!value.as_bool().unwrap_or(false))); + } + + if !self.definition.has_option(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + Ok(if self.options.contains_key(name) { + self.options[name].clone() + } else { + self.definition.get_option(name)?.get_default().clone() + }) + } + + pub fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + if self.definition.has_negation(name) { + let negated = self.definition.negation_to_name(name)?; + self.options + .insert(negated, PhpMixed::Bool(!value.as_bool().unwrap_or(false))); + + return Ok(()); + } else if !self.definition.has_option(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + self.options.insert(name.to_string(), value); + + Ok(()) + } + + pub fn has_option(&self, name: &str) -> bool { + self.definition.has_option(name) || self.definition.has_negation(name) + } + + /// Escapes a token through escapeshellarg if it contains unsafe chars. + pub fn escape_token(&self, token: &str) -> String { + let mut matches: Vec<Option<String>> = vec![]; + if shirabe_php_shim::preg_match("{^[\\w-]+$}", token, &mut matches) != 0 { + token.to_string() + } else { + shirabe_php_shim::escapeshellarg(token) + } + } + + pub fn set_stream(&mut self, stream: PhpMixed) { + self.stream = stream; + } + + pub fn get_stream(&self) -> Option<PhpMixed> { + match &self.stream { + PhpMixed::Null => None, + other => Some(other.clone()), + } + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs index 84b722d..e320878 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs @@ -1,7 +1,14 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; use shirabe_php_shim::PhpMixed; #[derive(Debug)] -pub struct InputArgument; +pub struct InputArgument { + name: String, + mode: i64, + default: PhpMixed, + description: String, +} impl InputArgument { pub const REQUIRED: i64 = 1; @@ -9,27 +16,85 @@ impl InputArgument { pub const IS_ARRAY: i64 = 4; pub fn new( - _name: &str, - _mode: Option<i64>, - _description: &str, - _default: Option<PhpMixed>, - ) -> Self { - todo!() + name: String, + mode: Option<i64>, + description: String, + default: PhpMixed, + ) -> anyhow::Result<Self> { + let mode = match mode { + None => Self::OPTIONAL, + Some(m) if m > 7 || m < 1 => { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: format!("Argument mode \"{}\" is not valid.", m), + code: 0, + }) + .into(), + ); + } + Some(m) => m, + }; + + let mut argument = InputArgument { + name, + mode, + description, + default: PhpMixed::Null, + }; + + argument.set_default(default)?; + + Ok(argument) } - pub fn get_name(&self) -> String { - todo!() + pub fn get_name(&self) -> &str { + &self.name } pub fn is_required(&self) -> bool { - todo!() + Self::REQUIRED == (Self::REQUIRED & self.mode) } pub fn is_array(&self) -> bool { - todo!() + Self::IS_ARRAY == (Self::IS_ARRAY & self.mode) + } + + pub fn set_default(&mut self, default: PhpMixed) -> anyhow::Result<()> { + if self.is_required() && !matches!(default, PhpMixed::Null) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "Cannot set a default value except for InputArgument::OPTIONAL mode." + .to_string(), + code: 0, + }) + .into()); + } + + let default = if self.is_array() { + match default { + PhpMixed::Null => PhpMixed::List(vec![]), + PhpMixed::List(_) => default, + _ => { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "A default value for an array argument must be an array." + .to_string(), + code: 0, + }) + .into()); + } + } + } else { + default + }; + + self.default = default; + Ok(()) + } + + pub fn get_default(&self) -> &PhpMixed { + &self.default } - pub fn get_default(&self) -> Option<PhpMixed> { - todo!() + pub fn get_description(&self) -> &str { + &self.description } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_aware_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_aware_interface.rs new file mode 100644 index 0000000..ae46465 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_aware_interface.rs @@ -0,0 +1,5 @@ +use crate::symfony::console::input::input_interface::InputInterface; + +pub trait InputAwareInterface { + fn set_input(&mut self, input: Box<dyn InputInterface>); +} diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs index f2d0318..5a3eef5 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs @@ -1,32 +1,472 @@ -use crate::symfony::console::input::InputArgument; -use crate::symfony::console::input::InputOption; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_option::InputOption; +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +use std::rc::Rc; +/// A InputDefinition represents a set of valid command line arguments and options. +/// +/// `InputArgument` and `InputOption` are stored behind `Rc` to model PHP's +/// shared object references; this also lets the definition be cloned cheaply +/// (PHP `bind` assigns the definition by reference). +#[derive(Debug, Clone)] +pub struct InputDefinition { + arguments: IndexMap<String, Rc<InputArgument>>, + required_count: i64, + last_array_argument: Option<Rc<InputArgument>>, + last_optional_argument: Option<Rc<InputArgument>>, + options: IndexMap<String, Rc<InputOption>>, + negations: IndexMap<String, String>, + shortcuts: IndexMap<String, String>, +} + +/// A definition entry is either an InputArgument or an InputOption. #[derive(Debug)] -pub struct InputDefinition; +pub enum DefinitionItem { + InputArgument(InputArgument), + InputOption(InputOption), +} impl InputDefinition { - pub fn new(_definition: Vec<PhpMixed>) -> Self { - todo!() + pub fn new(definition: Vec<DefinitionItem>) -> anyhow::Result<Self> { + let mut input_definition = InputDefinition { + arguments: IndexMap::new(), + required_count: 0, + last_array_argument: None, + last_optional_argument: None, + options: IndexMap::new(), + negations: IndexMap::new(), + shortcuts: IndexMap::new(), + }; + input_definition.set_definition(definition)?; + Ok(input_definition) + } + + /// Sets the definition of the input. + pub fn set_definition(&mut self, definition: Vec<DefinitionItem>) -> anyhow::Result<()> { + let mut arguments = vec![]; + let mut options = vec![]; + for item in definition { + match item { + DefinitionItem::InputOption(option) => { + options.push(option); + } + DefinitionItem::InputArgument(argument) => { + arguments.push(argument); + } + } + } + + self.set_arguments(arguments)?; + self.set_options(options)?; + + Ok(()) + } + + /// Sets the InputArgument objects. + pub fn set_arguments(&mut self, arguments: Vec<InputArgument>) -> anyhow::Result<()> { + self.arguments = IndexMap::new(); + self.required_count = 0; + self.last_optional_argument = None; + self.last_array_argument = None; + self.add_arguments(Some(arguments))?; + Ok(()) + } + + /// Adds an array of InputArgument objects. + pub fn add_arguments(&mut self, arguments: Option<Vec<InputArgument>>) -> anyhow::Result<()> { + if let Some(arguments) = arguments { + for argument in arguments { + self.add_argument(argument)?; + } + } + Ok(()) + } + + pub fn add_argument(&mut self, argument: InputArgument) -> anyhow::Result<()> { + let argument = Rc::new(argument); + + if self.arguments.contains_key(argument.get_name()) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An argument with name \"%s\" already exists.", + &[PhpMixed::String(argument.get_name().to_string())], + ), + code: 0, + }) + .into()); + } + + if let Some(last_array_argument) = &self.last_array_argument { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "Cannot add a required argument \"%s\" after an array argument \"%s\".", + &[ + PhpMixed::String(argument.get_name().to_string()), + PhpMixed::String(last_array_argument.get_name().to_string()), + ], + ), + code: 0, + }) + .into()); + } + + if argument.is_required() { + if let Some(last_optional_argument) = &self.last_optional_argument { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "Cannot add a required argument \"%s\" after an optional one \"%s\".", + &[ + PhpMixed::String(argument.get_name().to_string()), + PhpMixed::String(last_optional_argument.get_name().to_string()), + ], + ), + code: 0, + }) + .into()); + } + } + + if argument.is_array() { + self.last_array_argument = Some(Rc::clone(&argument)); + } + + if argument.is_required() { + self.required_count += 1; + } else { + self.last_optional_argument = Some(Rc::clone(&argument)); + } + + self.arguments + .insert(argument.get_name().to_string(), argument); + + Ok(()) + } + + /// Returns an InputArgument by name or by position. + pub fn get_argument(&self, name: &PhpMixed) -> anyhow::Result<Rc<InputArgument>> { + if !self.has_argument(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[name.clone()], + ), + code: 0, + }) + .into(), + ); + } + + match name { + PhpMixed::Int(index) => { + let arguments: Vec<Rc<InputArgument>> = self.arguments.values().cloned().collect(); + Ok(Rc::clone(&arguments[*index as usize])) + } + _ => { + let key = shirabe_php_shim::php_to_string(name); + Ok(Rc::clone(&self.arguments[&key])) + } + } + } + + /// Returns true if an InputArgument object exists by name or position. + pub fn has_argument(&self, name: &PhpMixed) -> bool { + match name { + PhpMixed::Int(index) => { + let arguments: Vec<Rc<InputArgument>> = self.arguments.values().cloned().collect(); + *index >= 0 && (*index as usize) < arguments.len() + } + _ => { + let key = shirabe_php_shim::php_to_string(name); + self.arguments.contains_key(&key) + } + } + } + + /// Gets the array of InputArgument objects. + pub fn get_arguments(&self) -> &IndexMap<String, Rc<InputArgument>> { + &self.arguments + } + + /// Returns the number of InputArguments. + pub fn get_argument_count(&self) -> i64 { + if self.last_array_argument.is_some() { + i64::MAX + } else { + self.arguments.len() as i64 + } + } + + /// Returns the number of required InputArguments. + pub fn get_argument_required_count(&self) -> i64 { + self.required_count + } + + pub fn get_argument_defaults(&self) -> IndexMap<String, PhpMixed> { + let mut values = IndexMap::new(); + for argument in self.arguments.values() { + values.insert( + argument.get_name().to_string(), + argument.get_default().clone(), + ); + } + + values + } + + /// Sets the InputOption objects. + pub fn set_options(&mut self, options: Vec<InputOption>) -> anyhow::Result<()> { + self.options = IndexMap::new(); + self.shortcuts = IndexMap::new(); + self.negations = IndexMap::new(); + self.add_options(options)?; + Ok(()) + } + + /// Adds an array of InputOption objects. + pub fn add_options(&mut self, options: Vec<InputOption>) -> anyhow::Result<()> { + for option in options { + self.add_option(option)?; + } + Ok(()) + } + + pub fn add_option(&mut self, option: InputOption) -> anyhow::Result<()> { + let option = Rc::new(option); + + if let Some(existing) = self.options.get(option.get_name()) { + if !option.equals(existing) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option named \"%s\" already exists.", + &[PhpMixed::String(option.get_name().to_string())], + ), + code: 0, + }) + .into()); + } + } + if self.negations.contains_key(option.get_name()) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option named \"%s\" already exists.", + &[PhpMixed::String(option.get_name().to_string())], + ), + code: 0, + }) + .into()); + } + + if let Some(shortcut) = option.get_shortcut() { + for shortcut in shirabe_php_shim::explode("|", shortcut) { + if let Some(existing_name) = self.shortcuts.get(&shortcut) { + if !option.equals(&self.options[existing_name]) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option with shortcut \"%s\" already exists.", + &[PhpMixed::String(shortcut.clone())], + ), + code: 0, + }) + .into()); + } + } + } + } + + self.options + .insert(option.get_name().to_string(), Rc::clone(&option)); + if let Some(shortcut) = option.get_shortcut() { + for shortcut in shirabe_php_shim::explode("|", shortcut) { + self.shortcuts + .insert(shortcut, option.get_name().to_string()); + } + } + + if option.is_negatable() { + let negated_name = format!("no-{}", option.get_name()); + if self.options.contains_key(&negated_name) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option named \"%s\" already exists.", + &[PhpMixed::String(negated_name.clone())], + ), + code: 0, + }) + .into()); + } + self.negations + .insert(negated_name, option.get_name().to_string()); + } + + Ok(()) + } + + /// Returns an InputOption by name. + pub fn get_option(&self, name: &str) -> anyhow::Result<Rc<InputOption>> { + if !self.has_option(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + Ok(Rc::clone(&self.options[name])) } - pub fn add_argument(&mut self, _argument: InputArgument) { - todo!() + /// Returns true if an InputOption object exists by name. + /// + /// This method can't be used to check if the user included the option when + /// executing the command (use getOption() instead). + pub fn has_option(&self, name: &str) -> bool { + self.options.contains_key(name) } - pub fn add_option(&mut self, _option: InputOption) { - todo!() + /// Gets the array of InputOption objects. + pub fn get_options(&self) -> &IndexMap<String, Rc<InputOption>> { + &self.options } - pub fn has_option(&self, _name: &str) -> bool { - todo!() + /// Returns true if an InputOption object exists by shortcut. + pub fn has_shortcut(&self, name: &str) -> bool { + self.shortcuts.contains_key(name) } - pub fn get_option(&self, _name: &str) -> anyhow::Result<PhpMixed> { - todo!() + /// Returns true if an InputOption object exists by negated name. + pub fn has_negation(&self, name: &str) -> bool { + self.negations.contains_key(name) } - pub fn has_argument(&self, _name: &str) -> bool { - todo!() + /// Gets an InputOption by shortcut. + pub fn get_option_for_shortcut(&self, shortcut: &str) -> anyhow::Result<Rc<InputOption>> { + self.get_option(&self.shortcut_to_name(shortcut)?) + } + + pub fn get_option_defaults(&self) -> IndexMap<String, PhpMixed> { + let mut values = IndexMap::new(); + for option in self.options.values() { + values.insert(option.get_name().to_string(), option.get_default().clone()); + } + + values + } + + /// Returns the InputOption name given a shortcut. + pub fn shortcut_to_name(&self, shortcut: &str) -> anyhow::Result<String> { + match self.shortcuts.get(shortcut) { + None => Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(shortcut.to_string())], + ), + code: 0, + }) + .into(), + ), + Some(name) => Ok(name.clone()), + } + } + + /// Returns the InputOption name given a negation. + pub fn negation_to_name(&self, negation: &str) -> anyhow::Result<String> { + match self.negations.get(negation) { + None => Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(negation.to_string())], + ), + code: 0, + }) + .into(), + ), + Some(name) => Ok(name.clone()), + } + } + + /// Gets the synopsis. + pub fn get_synopsis(&self, short: bool) -> String { + let mut elements: Vec<String> = vec![]; + + if short && !self.get_options().is_empty() { + elements.push("[options]".to_string()); + } else if !short { + for option in self.get_options().values() { + let mut value = String::new(); + if option.accept_value() { + value = shirabe_php_shim::sprintf( + " %s%s%s", + &[ + PhpMixed::String(if option.is_value_optional() { + "[".to_string() + } else { + String::new() + }), + PhpMixed::String(shirabe_php_shim::strtoupper(option.get_name())), + PhpMixed::String(if option.is_value_optional() { + "]".to_string() + } else { + String::new() + }), + ], + ); + } + + let shortcut = match option.get_shortcut() { + Some(shortcut) => { + shirabe_php_shim::sprintf("-%s|", &[PhpMixed::String(shortcut.to_string())]) + } + None => String::new(), + }; + let negation = if option.is_negatable() { + shirabe_php_shim::sprintf( + "|--no-%s", + &[PhpMixed::String(option.get_name().to_string())], + ) + } else { + String::new() + }; + elements.push(shirabe_php_shim::sprintf( + "[%s--%s%s%s]", + &[ + PhpMixed::String(shortcut), + PhpMixed::String(option.get_name().to_string()), + PhpMixed::String(value), + PhpMixed::String(negation), + ], + )); + } + } + + if elements.len() > 0 && !self.get_arguments().is_empty() { + elements.push("[--]".to_string()); + } + + let mut tail = String::new(); + for argument in self.get_arguments().values() { + let mut element = format!("<{}>", argument.get_name()); + if argument.is_array() { + element.push_str("..."); + } + + if !argument.is_required() { + element = format!("[{}", element); + tail.push(']'); + } + + elements.push(element); + } + + format!("{}{}", shirabe_php_shim::implode(" ", &elements), tail) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs index 14458e4..d271845 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs @@ -1,32 +1,39 @@ +use crate::symfony::console::input::input_definition::InputDefinition; use shirabe_php_shim::PhpMixed; -pub trait InputInterface: std::fmt::Debug { +pub trait InputInterface: std::fmt::Debug + shirabe_php_shim::AsAny { fn get_first_argument(&self) -> Option<String>; - fn has_parameter_option(&self, values: &[&str], only_params: bool) -> bool; + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool; + fn get_parameter_option( &self, - values: &[&str], + values: PhpMixed, default: PhpMixed, only_params: bool, ) -> PhpMixed; - fn bind( - &mut self, - definition: &crate::symfony::console::input::InputDefinition, - ) -> anyhow::Result<()>; - fn validate(&self) -> anyhow::Result<()>; + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()>; + + fn validate(&mut self) -> anyhow::Result<()>; + fn get_arguments(&self) -> indexmap::IndexMap<String, PhpMixed>; - fn get_argument(&self, name: &str) -> PhpMixed; + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed>; + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()>; + fn has_argument(&self, name: &str) -> bool; + fn get_options(&self) -> indexmap::IndexMap<String, PhpMixed>; - fn get_option(&self, name: &str) -> PhpMixed; + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed>; + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()>; + fn has_option(&self, name: &str) -> bool; + fn is_interactive(&self) -> bool; - fn set_interactive(&mut self, interactive: bool); - /// Equivalent to PHP `(string) $input` (Input::__toString). - fn to_input_string(&self) -> String { - todo!() - } + fn set_interactive(&mut self, interactive: bool); } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs index 673ca07..c929d03 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs @@ -1,7 +1,15 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; use shirabe_php_shim::PhpMixed; #[derive(Debug)] -pub struct InputOption; +pub struct InputOption { + name: String, + shortcut: Option<String>, + mode: i64, + default: PhpMixed, + description: String, +} impl InputOption { pub const VALUE_NONE: i64 = 1; @@ -11,36 +19,190 @@ impl InputOption { pub const VALUE_NEGATABLE: i64 = 16; pub fn new( - _name: &str, - _shortcut: Option<&str>, - _mode: Option<i64>, - _description: &str, - _default: PhpMixed, - ) -> Self { - todo!() + name: &str, + shortcut: PhpMixed, + mode: Option<i64>, + description: String, + default: PhpMixed, + ) -> anyhow::Result<Self> { + let name = if name.starts_with("--") { + name[2..].to_string() + } else { + name.to_string() + }; + + if name.is_empty() { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "An option name cannot be empty.".to_string(), + code: 0, + }) + .into(), + ); + } + + let shortcut = match shortcut { + PhpMixed::String(ref s) if s.is_empty() => None, + PhpMixed::List(ref v) if v.is_empty() => None, + PhpMixed::Bool(false) => None, + PhpMixed::Null => None, + PhpMixed::List(ref arr) => { + let parts: Vec<String> = arr + .iter() + .filter_map(|v| { + if let PhpMixed::String(s) = v.as_ref() { + Some(s.clone()) + } else { + None + } + }) + .collect(); + let joined = shirabe_php_shim::implode("|", &parts); + Self::normalize_shortcut(joined)? + } + PhpMixed::String(s) => Self::normalize_shortcut(s)?, + _ => None, + }; + + let mode = match mode { + None => Self::VALUE_NONE, + Some(m) if m >= (Self::VALUE_NEGATABLE << 1) || m < 1 => { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: format!("Option mode \"{}\" is not valid.", m), + code: 0, + }) + .into(), + ); + } + Some(m) => m, + }; + + let mut option = InputOption { + name, + shortcut, + mode, + description, + default: PhpMixed::Null, + }; + + if option.is_array() && !option.accept_value() { + return Err(InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "Impossible to have an option mode VALUE_IS_ARRAY if the option does not accept a value.".to_string(), + code: 0, + }) + .into()); + } + if option.is_negatable() && option.accept_value() { + return Err(InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "Impossible to have an option mode VALUE_NEGATABLE if the option also accepts a value.".to_string(), + code: 0, + }) + .into()); + } + + option.set_default(default)?; + + Ok(option) + } + + fn normalize_shortcut(s: String) -> anyhow::Result<Option<String>> { + let stripped = shirabe_php_shim::ltrim(&s, Some("-")); + let parts = shirabe_php_shim::preg_split(r"\|(-?)", &stripped).unwrap_or_default(); + let filtered: Vec<String> = + shirabe_php_shim::array_filter(&parts, |s: &String| !s.is_empty()); + let result = shirabe_php_shim::implode("|", &filtered); + if result.is_empty() { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "An option shortcut cannot be empty.".to_string(), + code: 0, + }) + .into(), + ); + } + Ok(Some(result)) + } + + pub fn get_shortcut(&self) -> Option<&str> { + self.shortcut.as_deref() } - pub fn get_name(&self) -> String { - todo!() + pub fn get_name(&self) -> &str { + &self.name } pub fn accept_value(&self) -> bool { - todo!() + self.is_value_required() || self.is_value_optional() } pub fn is_value_required(&self) -> bool { - todo!() + Self::VALUE_REQUIRED == (Self::VALUE_REQUIRED & self.mode) } pub fn is_value_optional(&self) -> bool { - todo!() + Self::VALUE_OPTIONAL == (Self::VALUE_OPTIONAL & self.mode) } pub fn is_array(&self) -> bool { - todo!() + Self::VALUE_IS_ARRAY == (Self::VALUE_IS_ARRAY & self.mode) + } + + pub fn is_negatable(&self) -> bool { + Self::VALUE_NEGATABLE == (Self::VALUE_NEGATABLE & self.mode) + } + + pub fn set_default(&mut self, default: PhpMixed) -> anyhow::Result<()> { + if Self::VALUE_NONE == (Self::VALUE_NONE & self.mode) && !matches!(default, PhpMixed::Null) + { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "Cannot set a default value when using InputOption::VALUE_NONE mode." + .to_string(), + code: 0, + }) + .into()); + } + + let default = if self.is_array() { + match default { + PhpMixed::Null => PhpMixed::List(vec![]), + PhpMixed::List(_) => default, + _ => { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "A default value for an array option must be an array." + .to_string(), + code: 0, + }) + .into()); + } + } + } else { + default + }; + + self.default = if self.accept_value() || self.is_negatable() { + default + } else { + PhpMixed::Bool(false) + }; + Ok(()) + } + + pub fn get_default(&self) -> &PhpMixed { + &self.default + } + + pub fn get_description(&self) -> &str { + &self.description } - pub fn get_default(&self) -> PhpMixed { - todo!() + pub fn equals(&self, option: &InputOption) -> bool { + option.get_name() == self.get_name() + && option.get_shortcut() == self.get_shortcut() + && option.get_default() == self.get_default() + && option.is_negatable() == self.is_negatable() + && option.is_array() == self.is_array() + && option.is_value_required() == self.is_value_required() + && option.is_value_optional() == self.is_value_optional() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/mod.rs b/crates/shirabe-external-packages/src/symfony/console/input/mod.rs index 8c6a28f..d4813b4 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/mod.rs @@ -1,13 +1,19 @@ +pub mod argv_input; pub mod array_input; +pub mod input; pub mod input_argument; +pub mod input_aware_interface; pub mod input_definition; pub mod input_interface; pub mod input_option; pub mod streamable_input_interface; pub mod string_input; +pub use argv_input::*; pub use array_input::*; +pub use input::*; pub use input_argument::*; +pub use input_aware_interface::*; pub use input_definition::*; pub use input_interface::*; pub use input_option::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs index aea2bf2..7279dc4 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs @@ -1,7 +1,8 @@ -use crate::symfony::console::input::InputInterface; +use crate::symfony::console::input::input_interface::InputInterface; use shirabe_php_shim::PhpMixed; pub trait StreamableInputInterface: InputInterface { fn set_stream(&mut self, stream: PhpMixed); + fn get_stream(&self) -> Option<PhpMixed>; } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs index 34b2bfc..aad1d86 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs @@ -1,66 +1,193 @@ -use crate::symfony::console::input::InputDefinition; -use crate::symfony::console::input::InputInterface; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::input::argv_input::ArgvInput; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +/// StringInput represents an input provided as a string. +/// +/// Usage: +/// +/// $input = new StringInput('foo --bar="foobar"'); #[derive(Debug)] -pub struct StringInput; +pub struct StringInput { + pub(crate) inner: ArgvInput, +} impl StringInput { - pub fn new(_input: &str) -> Self { - todo!() + pub const REGEX_STRING: &'static str = r#"([^\s]+?)(?:\s|(?<!\\)"|(?<!\\)'|$)"#; + pub const REGEX_UNQUOTED_STRING: &'static str = r#"([^\s\\]+?)"#; + pub const REGEX_QUOTED_STRING: &'static str = + r#"(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)')"#; + + pub fn new(input: &str) -> anyhow::Result<Self> { + // parent::__construct([]) + let inner = ArgvInput::new(Some(vec![]), None)?; + + let mut string_input = StringInput { inner }; + + let tokens = string_input.tokenize(input)?; + string_input.inner.set_tokens(tokens); + + Ok(string_input) + } + + /// Tokenizes a string. + fn tokenize(&self, input: &str) -> anyhow::Result<Vec<String>> { + let bytes = input.as_bytes(); + let mut tokens: Vec<String> = vec![]; + let length = shirabe_php_shim::strlen(input); + let mut cursor: i64 = 0; + let mut token: Option<String> = None; + while cursor < length { + if bytes[cursor as usize] == b'\\' { + cursor += 1; + let next: String = match bytes.get(cursor as usize) { + Some(b) => String::from_utf8_lossy(&[*b]).into_owned(), + None => String::new(), + }; + token = Some(format!("{}{}", token.unwrap_or_default(), next)); + cursor += 1; + continue; + } + + let mut m: Vec<String> = vec![]; + if shirabe_php_shim::preg_match_offset(r"/\s+/A", input, &mut m, 0, cursor) { + if token.is_some() { + tokens.push(token.take().unwrap()); + } + cursor += shirabe_php_shim::strlen(&m[0]); + } else if shirabe_php_shim::preg_match_offset( + &format!(r#"/([^="'\s]+?)(=?)({}+)/A"#, Self::REGEX_QUOTED_STRING), + input, + &mut m, + 0, + cursor, + ) { + let inner = shirabe_php_shim::substr(&m[3], 1, Some(-1)); + let replaced = + shirabe_php_shim::str_replace_arr(&["\"'", "'\"", "''", "\"\""], "", &inner); + token = Some(format!( + "{}{}{}{}", + token.unwrap_or_default(), + m[1], + m[2], + shirabe_php_shim::stripcslashes(&replaced) + )); + cursor += shirabe_php_shim::strlen(&m[0]); + } else if shirabe_php_shim::preg_match_offset( + &format!(r"/{}/A", Self::REGEX_QUOTED_STRING), + input, + &mut m, + 0, + cursor, + ) { + token = Some(format!( + "{}{}", + token.unwrap_or_default(), + shirabe_php_shim::stripcslashes(&shirabe_php_shim::substr(&m[0], 1, Some(-1))) + )); + cursor += shirabe_php_shim::strlen(&m[0]); + } else if shirabe_php_shim::preg_match_offset( + &format!(r"/{}/A", Self::REGEX_UNQUOTED_STRING), + input, + &mut m, + 0, + cursor, + ) { + token = Some(format!("{}{}", token.unwrap_or_default(), m[1])); + cursor += shirabe_php_shim::strlen(&m[0]); + } else { + // should never happen + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Unable to parse input near \"... %s ...\".", + &[PhpMixed::String(shirabe_php_shim::substr( + input, + cursor, + Some(10), + ))], + ), + code: 0, + }) + .into(), + ); + } + } + + if let Some(token) = token { + tokens.push(token); + } + + Ok(tokens) } } impl InputInterface for StringInput { fn get_first_argument(&self) -> Option<String> { - todo!() + self.inner.get_first_argument() } - fn has_parameter_option(&self, _values: &[&str], _only_params: bool) -> bool { - todo!() + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + InputInterface::has_parameter_option(&self.inner, values, only_params) } + fn get_parameter_option( &self, - _values: &[&str], - _default: PhpMixed, - _only_params: bool, + values: PhpMixed, + default: PhpMixed, + only_params: bool, ) -> PhpMixed { - todo!() + InputInterface::get_parameter_option(&self.inner, values, default, only_params) } - fn bind(&mut self, _definition: &InputDefinition) -> anyhow::Result<()> { - todo!() + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + InputInterface::bind(&mut self.inner, definition) } - fn validate(&self) -> anyhow::Result<()> { - todo!() + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() } + fn get_arguments(&self) -> IndexMap<String, PhpMixed> { - todo!() + InputInterface::get_arguments(&self.inner) } - fn get_argument(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_argument(name) } - fn set_argument(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_argument(name, value) } - fn has_argument(&self, _name: &str) -> bool { - todo!() + + fn has_argument(&self, name: &str) -> bool { + self.inner.has_argument(name) } + fn get_options(&self) -> IndexMap<String, PhpMixed> { - todo!() + InputInterface::get_options(&self.inner) } - fn get_option(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_option(name) } - fn set_option(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_option(name, value) } - fn has_option(&self, _name: &str) -> bool { - todo!() + + fn has_option(&self, name: &str) -> bool { + self.inner.has_option(name) } + fn is_interactive(&self) -> bool { - todo!() + self.inner.is_interactive() } - fn set_interactive(&mut self, _interactive: bool) { - todo!() + + fn set_interactive(&mut self, interactive: bool) { + self.inner.set_interactive(interactive) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/mod.rs b/crates/shirabe-external-packages/src/symfony/console/mod.rs index b5b557d..688d21f 100644 --- a/crates/shirabe-external-packages/src/symfony/console/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/mod.rs @@ -1,25 +1,39 @@ pub mod application; +pub mod attribute; +pub mod color; pub mod command; +pub mod command_loader; pub mod completion; +pub mod console_events; +pub mod cursor; +pub mod descriptor; +pub mod event; pub mod exception; pub mod formatter; pub mod helper; pub mod input; pub mod output; pub mod question; -pub mod single_command_application; +pub mod signal_registry; pub mod style; pub mod terminal; pub use application::*; +pub use attribute::*; +pub use color::*; pub use command::*; +pub use command_loader::*; pub use completion::*; +pub use console_events::*; +pub use cursor::*; +pub use descriptor::*; +pub use event::*; pub use exception::*; pub use formatter::*; pub use helper::*; pub use input::*; pub use output::*; pub use question::*; -pub use single_command_application::*; +pub use signal_registry::*; pub use style::*; pub use terminal::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/output/buffered_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/buffered_output.rs new file mode 100644 index 0000000..517b985 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/output/buffered_output.rs @@ -0,0 +1,82 @@ +use crate::symfony::console::formatter::OutputFormatterInterface; +use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::output::{DoWrite, Output}; + +#[derive(Debug)] +pub struct BufferedOutput { + inner: Output, + buffer: std::cell::RefCell<String>, +} + +impl BufferedOutput { + pub fn new( + verbosity: Option<i64>, + decorated: bool, + formatter: Option<std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>>, + ) -> Self { + Self { + inner: Output::new(verbosity, decorated, formatter), + buffer: std::cell::RefCell::new(String::new()), + } + } + + /// Empties buffer and returns its content. + pub fn fetch(&self) -> String { + let content = self.buffer.borrow().clone(); + *self.buffer.borrow_mut() = String::new(); + + content + } +} + +impl DoWrite for BufferedOutput { + fn do_write(&self, message: &str, newline: bool) { + self.buffer.borrow_mut().push_str(message); + + if newline { + self.buffer.borrow_mut().push_str(shirabe_php_shim::PHP_EOL); + } + } +} + +impl OutputInterface for BufferedOutput { + fn write(&self, messages: &[String], newline: bool, options: i64) { + self.inner.write(self, messages, newline, options); + } + fn writeln(&self, messages: &[String], options: i64) { + self.inner.writeln(self, messages, options); + } + fn set_verbosity(&self, level: i64) { + self.inner.set_verbosity(level); + } + fn get_verbosity(&self) -> i64 { + self.inner.get_verbosity() + } + fn is_quiet(&self) -> bool { + self.inner.is_quiet() + } + fn is_verbose(&self) -> bool { + self.inner.is_verbose() + } + fn is_very_verbose(&self) -> bool { + self.inner.is_very_verbose() + } + fn is_debug(&self) -> bool { + self.inner.is_debug() + } + fn set_decorated(&self, decorated: bool) { + self.inner.set_decorated(decorated); + } + fn is_decorated(&self) -> bool { + self.inner.is_decorated() + } + fn set_formatter( + &self, + formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, + ) { + self.inner.set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>> { + self.inner.get_formatter() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs index b3010b9..c6788c0 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs @@ -1,76 +1,191 @@ use crate::symfony::console::formatter::OutputFormatterInterface; use crate::symfony::console::output::ConsoleOutputInterface; use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::console_section_output::ConsoleSectionOutput; +use crate::symfony::console::output::output_interface::VERBOSITY_NORMAL; +use crate::symfony::console::output::stream_output::StreamOutput; +/// ConsoleOutput is the default class for all CLI output. It uses STDOUT and STDERR. +/// +/// This class is a convenient wrapper around `StreamOutput` for both STDOUT and STDERR. +/// +/// $output = new ConsoleOutput(); +/// +/// This is equivalent to: +/// +/// $output = new StreamOutput(fopen('php://stdout', 'w')); +/// $stdErr = new StreamOutput(fopen('php://stderr', 'w')); #[derive(Debug)] -pub struct ConsoleOutput; +pub struct ConsoleOutput { + inner: StreamOutput, + stderr: std::cell::RefCell<std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>>, + console_section_outputs: + std::rc::Rc<std::cell::RefCell<Vec<std::rc::Rc<std::cell::RefCell<ConsoleSectionOutput>>>>>, +} impl ConsoleOutput { + /// `$verbosity` defaults to `self::VERBOSITY_NORMAL`; pass `None` to use it. pub fn new( - _verbosity: i64, - _decorated: Option<bool>, - _formatter: Option<std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>>, - ) -> Self { - todo!() + verbosity: Option<i64>, + decorated: Option<bool>, + formatter: Option<std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>>, + ) -> anyhow::Result<Self> { + let verbosity = verbosity.unwrap_or(VERBOSITY_NORMAL); + + let inner = StreamOutput::new( + Self::open_output_stream(), + Some(verbosity), + decorated, + formatter.clone(), + )? + .expect("ConsoleOutput stdout stream is always valid"); + + let this = if formatter.is_none() { + // for BC reasons, stdErr has it own Formatter only when user don't inject a specific formatter. + let stderr = + StreamOutput::new(Self::open_error_stream(), Some(verbosity), decorated, None)? + .expect("ConsoleOutput stderr stream is always valid"); + Self { + inner, + stderr: std::cell::RefCell::new(std::rc::Rc::new(std::cell::RefCell::new(stderr))), + console_section_outputs: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())), + } + } else { + let actual_decorated = inner.is_decorated(); + let stderr = StreamOutput::new( + Self::open_error_stream(), + Some(verbosity), + decorated, + Some(inner.get_formatter()), + )? + .expect("ConsoleOutput stderr stream is always valid"); + let stderr_decorated = stderr.is_decorated(); + let this = Self { + inner, + stderr: std::cell::RefCell::new(std::rc::Rc::new(std::cell::RefCell::new(stderr))), + console_section_outputs: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())), + }; + + if decorated.is_none() { + this.set_decorated(actual_decorated && stderr_decorated); + } + + this + }; + + Ok(this) } -} -impl ConsoleOutputInterface for ConsoleOutput { - fn get_error_output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> { - todo!() + /// Returns true if current environment supports writing console output to + /// STDOUT. + fn has_stdout_support() -> bool { + false == Self::is_running_os400() + } + + /// Returns true if current environment supports writing console output to + /// STDERR. + fn has_stderr_support() -> bool { + false == Self::is_running_os400() } - fn set_error_output(&mut self, _error: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) { - todo!() + /// Checks if current executing environment is IBM iSeries (OS400), which + /// doesn't properly convert character-encodings between ASCII to EBCDIC. + fn is_running_os400() -> bool { + let checks = [ + if shirabe_php_shim::function_exists("php_uname") { + shirabe_php_shim::php_uname("s") + } else { + String::new() + }, + shirabe_php_shim::getenv("OSTYPE").unwrap_or_default(), + shirabe_php_shim::PHP_OS.to_string(), + ]; + + shirabe_php_shim::stripos(&shirabe_php_shim::implode(";", &checks), "OS400").is_some() + } + + fn open_output_stream() -> shirabe_php_shim::PhpResource { + if !Self::has_stdout_support() { + return shirabe_php_shim::php_fopen_resource("php://output", "w"); + } + + // Use STDOUT when possible to prevent from opening too many file descriptors + shirabe_php_shim::php_stdout_resource() + } + + fn open_error_stream() -> shirabe_php_shim::PhpResource { + if !Self::has_stderr_support() { + return shirabe_php_shim::php_fopen_resource("php://output", "w"); + } + + // Use STDERR when possible to prevent from opening too many file descriptors + shirabe_php_shim::php_stderr_resource() } } -impl OutputInterface for ConsoleOutput { - fn is_console_output_interface(&self) -> bool { - true +impl ConsoleOutputInterface for ConsoleOutput { + /// Creates a new output section. + fn section(&self) -> std::rc::Rc<std::cell::RefCell<ConsoleSectionOutput>> { + // ConsoleSectionOutput::new pushes itself into the shared sections list. + ConsoleSectionOutput::new( + self.inner.get_stream().clone(), + &self.console_section_outputs, + self.get_verbosity(), + self.is_decorated(), + self.get_formatter(), + ) + } + + fn get_error_output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> { + self.stderr.borrow().clone() } - fn as_console_output_interface(&self) -> Option<&dyn ConsoleOutputInterface> { - Some(self) + fn set_error_output(&self, error: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) { + *self.stderr.borrow_mut() = error; } +} - fn write(&self, _messages: &str, _newline: bool, _type: i64) { - todo!() +impl OutputInterface for ConsoleOutput { + fn write(&self, messages: &[String], newline: bool, options: i64) { + self.inner.write(messages, newline, options); } - fn writeln(&self, _messages: &str, _type: i64) { - todo!() + fn writeln(&self, messages: &[String], options: i64) { + self.inner.writeln(messages, options); } - fn set_verbosity(&self, _level: i64) { - todo!() + fn set_verbosity(&self, level: i64) { + self.inner.set_verbosity(level); + self.stderr.borrow().borrow().set_verbosity(level); } fn get_verbosity(&self) -> i64 { - todo!() + self.inner.get_verbosity() } fn is_quiet(&self) -> bool { - todo!() + self.inner.is_quiet() } fn is_verbose(&self) -> bool { - todo!() + self.inner.is_verbose() } fn is_very_verbose(&self) -> bool { - todo!() + self.inner.is_very_verbose() } fn is_debug(&self) -> bool { - todo!() + self.inner.is_debug() } - fn set_decorated(&self, _decorated: bool) { - todo!() + fn set_decorated(&self, decorated: bool) { + self.inner.set_decorated(decorated); + self.stderr.borrow().borrow().set_decorated(decorated); } fn is_decorated(&self) -> bool { - todo!() + self.inner.is_decorated() } fn set_formatter( &self, - _formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, + formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, ) { - todo!() + self.inner.set_formatter(formatter.clone()); + self.stderr.borrow().borrow().set_formatter(formatter); } fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>> { - todo!() + self.inner.get_formatter() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/output/console_output_interface.rs b/crates/shirabe-external-packages/src/symfony/console/output/console_output_interface.rs index 0b5b432..055f7a0 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/console_output_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/console_output_interface.rs @@ -1,6 +1,13 @@ +use crate::symfony::console::output::ConsoleSectionOutput; use crate::symfony::console::output::OutputInterface; +/// ConsoleOutputInterface is the interface implemented by ConsoleOutput class. +/// This adds information about stderr and section output stream. pub trait ConsoleOutputInterface: OutputInterface { + /// Gets the OutputInterface for errors. fn get_error_output(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>; - fn set_error_output(&mut self, error: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>); + + fn set_error_output(&self, error: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>); + + fn section(&self) -> std::rc::Rc<std::cell::RefCell<ConsoleSectionOutput>>; } diff --git a/crates/shirabe-external-packages/src/symfony/console/output/console_section_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/console_section_output.rs new file mode 100644 index 0000000..586752d --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/output/console_section_output.rs @@ -0,0 +1,209 @@ +use crate::symfony::console::formatter::OutputFormatterInterface; +use crate::symfony::console::helper::Helper; +use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::output::DoWrite; +use crate::symfony::console::output::stream_output::StreamOutput; +use crate::symfony::console::terminal::Terminal; + +type Sections = + std::rc::Rc<std::cell::RefCell<Vec<std::rc::Rc<std::cell::RefCell<ConsoleSectionOutput>>>>>; + +#[derive(Debug)] +pub struct ConsoleSectionOutput { + inner: StreamOutput, + content: std::cell::RefCell<Vec<String>>, + lines: std::cell::Cell<i64>, + sections: Sections, + terminal: Terminal, +} + +impl ConsoleSectionOutput { + /// `$sections` is shared by reference (PHP `array &$sections`); the new instance + /// is unshifted into it. + pub fn new( + stream: shirabe_php_shim::PhpResource, + sections: &Sections, + verbosity: i64, + decorated: bool, + formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, + ) -> std::rc::Rc<std::cell::RefCell<Self>> { + let inner = StreamOutput::new(stream, Some(verbosity), Some(decorated), Some(formatter)) + .expect("ConsoleSectionOutput stream operation must not fatal") + .expect("ConsoleSectionOutput stream is valid"); + + let this = std::rc::Rc::new(std::cell::RefCell::new(Self { + inner, + content: std::cell::RefCell::new(Vec::new()), + lines: std::cell::Cell::new(0), + sections: sections.clone(), + terminal: Terminal::new(), + })); + + shirabe_php_shim::array_unshift(&mut sections.borrow_mut(), this.clone()); + + this + } + + /// Clears previous output for this section. + /// + /// `$lines` is the number of lines to clear. If null, then the entire output + /// of this section is cleared. + pub fn clear(&self, lines: Option<i64>) { + if self.content.borrow().is_empty() || !self.is_decorated() { + return; + } + + let lines = if let Some(lines) = lines.filter(|l| *l != 0) { + // Multiply lines by 2 to cater for each new line added between content + shirabe_php_shim::array_splice( + &mut self.content.borrow_mut(), + -(lines * 2), + None, + Vec::new(), + ); + lines + } else { + let lines = self.lines.get(); + *self.content.borrow_mut() = Vec::new(); + lines + }; + + self.lines.set(self.lines.get() - lines); + + let erased = self.pop_stream_content_until_current_section(lines); + self.inner.do_write(&erased, false); + } + + /// Overwrites the previous output with a new message. + pub fn overwrite(&self, message: &[String]) { + self.clear(None); + self.writeln( + message, + crate::symfony::console::output::output_interface::OUTPUT_NORMAL, + ); + } + + pub fn get_content(&self) -> String { + self.content.borrow().join("") + } + + /// @internal + pub fn add_content(&self, input: &str) { + for line_content in shirabe_php_shim::explode(shirabe_php_shim::PHP_EOL, input) { + let count = shirabe_php_shim::ceil( + self.get_display_length(&line_content) as f64 / self.terminal.get_width() as f64, + ); + self.lines + .set(self.lines.get() + if count != 0.0 { count as i64 } else { 1 }); + self.content.borrow_mut().push(line_content); + self.content + .borrow_mut() + .push(shirabe_php_shim::PHP_EOL.to_string()); + } + } + + /// At initial stage, cursor is at the end of stream output. This method makes cursor crawl upwards until it hits + /// current section. Then it erases content it crawled through. Optionally, it erases part of current section too. + /// + /// `$numberOfLinesToClearFromCurrentSection` defaults to 0 in PHP. + fn pop_stream_content_until_current_section( + &self, + number_of_lines_to_clear_from_current_section: i64, + ) -> String { + let mut number_of_lines_to_clear = number_of_lines_to_clear_from_current_section; + let mut erased_content: Vec<String> = Vec::new(); + + for section in self.sections.borrow().iter() { + // PHP: `if ($section === $this) break;` — identity comparison against $this. + // The current section is the same object stored in the shared list. + if section.as_ptr() == (self as *const Self).cast_mut() { + break; + } + + let section_ref = section.borrow(); + number_of_lines_to_clear += section_ref.lines.get(); + erased_content.push(section_ref.get_content()); + } + + if number_of_lines_to_clear > 0 { + // move cursor up n lines + self.inner.do_write( + &shirabe_php_shim::sprintf( + "\x1b[%dA", + &[shirabe_php_shim::PhpMixed::Int(number_of_lines_to_clear)], + ), + false, + ); + // erase to end of screen + self.inner.do_write("\x1b[0J", false); + } + + shirabe_php_shim::array_reverse(&erased_content, false).join("") + } + + fn get_display_length(&self, text: &str) -> i64 { + Helper::width(&Helper::remove_decoration( + &mut *self.get_formatter().borrow_mut(), + &shirabe_php_shim::str_replace("\t", " ", text), + )) + } +} + +impl DoWrite for ConsoleSectionOutput { + fn do_write(&self, message: &str, newline: bool) { + if !self.is_decorated() { + self.inner.do_write(message, newline); + + return; + } + + let erased_content = self.pop_stream_content_until_current_section(0); + + self.add_content(message); + + self.inner.do_write(message, true); + self.inner.do_write(&erased_content, false); + } +} + +impl OutputInterface for ConsoleSectionOutput { + fn write(&self, messages: &[String], newline: bool, options: i64) { + self.inner.inner().write(self, messages, newline, options); + } + fn writeln(&self, messages: &[String], options: i64) { + self.inner.inner().writeln(self, messages, options); + } + fn set_verbosity(&self, level: i64) { + self.inner.set_verbosity(level); + } + fn get_verbosity(&self) -> i64 { + self.inner.get_verbosity() + } + fn is_quiet(&self) -> bool { + self.inner.is_quiet() + } + fn is_verbose(&self) -> bool { + self.inner.is_verbose() + } + fn is_very_verbose(&self) -> bool { + self.inner.is_very_verbose() + } + fn is_debug(&self) -> bool { + self.inner.is_debug() + } + fn set_decorated(&self, decorated: bool) { + self.inner.set_decorated(decorated); + } + fn is_decorated(&self) -> bool { + self.inner.is_decorated() + } + fn set_formatter( + &self, + formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, + ) { + self.inner.set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>> { + self.inner.get_formatter() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/output/mod.rs b/crates/shirabe-external-packages/src/symfony/console/output/mod.rs index ac2b687..23a8e15 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/mod.rs @@ -1,9 +1,17 @@ +pub mod buffered_output; pub mod console_output; pub mod console_output_interface; +pub mod console_section_output; +pub mod output; pub mod output_interface; pub mod stream_output; +pub mod trimmed_buffer_output; +pub use buffered_output::*; pub use console_output::*; pub use console_output_interface::*; +pub use console_section_output::*; +pub use output::*; pub use output_interface::*; pub use stream_output::*; +pub use trimmed_buffer_output::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/output/output.rs b/crates/shirabe-external-packages/src/symfony/console/output/output.rs new file mode 100644 index 0000000..e2469e9 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/output/output.rs @@ -0,0 +1,157 @@ +use crate::symfony::console::formatter::OutputFormatter; +use crate::symfony::console::formatter::OutputFormatterInterface; +use crate::symfony::console::output::output_interface::{ + OUTPUT_NORMAL, OUTPUT_PLAIN, OUTPUT_RAW, VERBOSITY_DEBUG, VERBOSITY_NORMAL, VERBOSITY_QUIET, + VERBOSITY_VERBOSE, VERBOSITY_VERY_VERBOSE, +}; + +/// Base class for output classes. +/// +/// There are five levels of verbosity: +/// +/// * normal: no option passed (normal output) +/// * verbose: -v (more output) +/// * very verbose: -vv (highly extended output) +/// * debug: -vvv (all debug output) +/// * quiet: -q (no output) +pub struct Output { + verbosity: std::cell::Cell<i64>, + formatter: std::cell::RefCell<std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>>, +} + +impl std::fmt::Debug for Output { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Output") + .field("verbosity", &self.verbosity) + .finish_non_exhaustive() + } +} + +/// Subclasses provide the concrete sink by implementing `do_write`. +/// +/// This mirrors the PHP `abstract protected function doWrite(string $message, bool $newline)`. +pub trait DoWrite { + fn do_write(&self, message: &str, newline: bool); +} + +impl Output { + pub fn new( + verbosity: Option<i64>, + decorated: bool, + formatter: Option<std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>>, + ) -> Self { + let verbosity = verbosity.unwrap_or(VERBOSITY_NORMAL); + let formatter = formatter.unwrap_or_else(|| { + std::rc::Rc::new(std::cell::RefCell::new(OutputFormatter::new( + false, + indexmap::IndexMap::new(), + ))) + }); + formatter.borrow_mut().set_decorated(decorated); + Self { + verbosity: std::cell::Cell::new(verbosity), + formatter: std::cell::RefCell::new(formatter), + } + } + + pub fn set_formatter( + &self, + formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, + ) { + *self.formatter.borrow_mut() = formatter; + } + + pub fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>> { + self.formatter.borrow().clone() + } + + pub fn set_decorated(&self, decorated: bool) { + self.formatter + .borrow() + .borrow_mut() + .set_decorated(decorated); + } + + pub fn is_decorated(&self) -> bool { + self.formatter.borrow().borrow().is_decorated() + } + + pub fn set_verbosity(&self, level: i64) { + self.verbosity.set(level); + } + + pub fn get_verbosity(&self) -> i64 { + self.verbosity.get() + } + + pub fn is_quiet(&self) -> bool { + VERBOSITY_QUIET == self.verbosity.get() + } + + pub fn is_verbose(&self) -> bool { + VERBOSITY_VERBOSE <= self.verbosity.get() + } + + pub fn is_very_verbose(&self) -> bool { + VERBOSITY_VERY_VERBOSE <= self.verbosity.get() + } + + pub fn is_debug(&self) -> bool { + VERBOSITY_DEBUG <= self.verbosity.get() + } + + pub fn writeln(&self, do_writer: &dyn DoWrite, messages: &[String], options: i64) { + self.write(do_writer, messages, true, options); + } + + pub fn write(&self, do_writer: &dyn DoWrite, messages: &[String], newline: bool, options: i64) { + let types = OUTPUT_NORMAL | OUTPUT_RAW | OUTPUT_PLAIN; + let r#type = { + let masked = types & options; + if masked != 0 { masked } else { OUTPUT_NORMAL } + }; + + let verbosities = VERBOSITY_QUIET + | VERBOSITY_NORMAL + | VERBOSITY_VERBOSE + | VERBOSITY_VERY_VERBOSE + | VERBOSITY_DEBUG; + let verbosity = { + let masked = verbosities & options; + if masked != 0 { + masked + } else { + VERBOSITY_NORMAL + } + }; + + if verbosity > self.get_verbosity() { + return; + } + + for message in messages { + let message = match r#type { + OUTPUT_NORMAL => self + .formatter + .borrow() + .borrow_mut() + .format(Some(message)) + .unwrap() + .unwrap_or_default(), + OUTPUT_RAW => message.clone(), + OUTPUT_PLAIN => shirabe_php_shim::strip_tags( + &self + .formatter + .borrow() + .borrow_mut() + .format(Some(message)) + .unwrap() + .unwrap_or_default(), + ), + _ => message.clone(), + }; + + do_writer.do_write(&message, newline); + } + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs b/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs index 661cd7f..1b52c20 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/output_interface.rs @@ -1,48 +1,54 @@ use crate::symfony::console::formatter::OutputFormatterInterface; -use crate::symfony::console::output::ConsoleOutputInterface; -pub trait OutputInterface: std::fmt::Debug { - // PHP class semantics: OutputInterface methods take &self with interior mutability, - // because output objects are shared by reference across the PHP code. - fn write(&self, messages: &str, newline: bool, r#type: i64); - fn writeln(&self, messages: &str, r#type: i64); +pub const VERBOSITY_QUIET: i64 = 16; +pub const VERBOSITY_NORMAL: i64 = 32; +pub const VERBOSITY_VERBOSE: i64 = 64; +pub const VERBOSITY_VERY_VERBOSE: i64 = 128; +pub const VERBOSITY_DEBUG: i64 = 256; + +pub const OUTPUT_NORMAL: i64 = 1; +pub const OUTPUT_RAW: i64 = 2; +pub const OUTPUT_PLAIN: i64 = 4; + +/// OutputInterface is the interface implemented by all Output classes. +pub trait OutputInterface: std::fmt::Debug + shirabe_php_shim::AsAny { + /// Writes a message to the output. + /// + /// `$messages` is a single string or an iterable of strings. + fn write(&self, messages: &[String], newline: bool, options: i64); + + /// Writes a message to the output and adds a newline at the end. + fn writeln(&self, messages: &[String], options: i64); + + /// Sets the verbosity of the output. fn set_verbosity(&self, level: i64); + + /// Gets the current verbosity of the output. fn get_verbosity(&self) -> i64; + + /// Returns whether verbosity is quiet (-q). fn is_quiet(&self) -> bool; + + /// Returns whether verbosity is verbose (-v). fn is_verbose(&self) -> bool; + + /// Returns whether verbosity is very verbose (-vv). fn is_very_verbose(&self) -> bool; + + /// Returns whether verbosity is debug (-vvv). fn is_debug(&self) -> bool; + + /// Sets the decorated flag. fn set_decorated(&self, decorated: bool); + + /// Gets the decorated flag. fn is_decorated(&self) -> bool; + fn set_formatter( &self, formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, ); - fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>; - - /// PHP: `$output instanceof ConsoleOutputInterface`. Default false; ConsoleOutput overrides. - fn is_console_output_interface(&self) -> bool { - false - } - /// PHP: `$output instanceof ConsoleOutputInterface`. Returns the output as a - /// ConsoleOutputInterface trait object when it is one. Default None; ConsoleOutput overrides. - fn as_console_output_interface(&self) -> Option<&dyn ConsoleOutputInterface> { - None - } - - /// PHP: only StreamOutput exposes `getStream()`. Default panics for outputs without one. - fn get_stream(&self) -> shirabe_php_shim::PhpResource { - todo!("get_stream not available on this OutputInterface implementation") - } + /// Returns current output formatter instance. + fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>; } - -pub const VERBOSITY_QUIET: i64 = 16; -pub const VERBOSITY_NORMAL: i64 = 32; -pub const VERBOSITY_VERBOSE: i64 = 64; -pub const VERBOSITY_VERY_VERBOSE: i64 = 128; -pub const VERBOSITY_DEBUG: i64 = 256; - -pub const OUTPUT_NORMAL: i64 = 1; -pub const OUTPUT_RAW: i64 = 2; -pub const OUTPUT_PLAIN: i64 = 4; diff --git a/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs index da7dbf4..46ae4dc 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs @@ -1,57 +1,183 @@ +use crate::symfony::console::exception::InvalidArgumentException; use crate::symfony::console::formatter::OutputFormatterInterface; use crate::symfony::console::output::OutputInterface; -use shirabe_php_shim::PhpMixed; +use crate::symfony::console::output::output::{DoWrite, Output}; +use crate::symfony::console::output::output_interface::VERBOSITY_NORMAL; +/// StreamOutput writes the output to a given stream. +/// +/// Usage: +/// +/// $output = new StreamOutput(fopen('php://stdout', 'w')); +/// +/// As `StreamOutput` can use any stream, you can also use a file: +/// +/// $output = new StreamOutput(fopen('/path/to/output.log', 'a', false)); #[derive(Debug)] -pub struct StreamOutput; +pub struct StreamOutput { + inner: Output, + stream: shirabe_php_shim::PhpResource, +} impl StreamOutput { - pub fn new(_stream: PhpMixed, _verbosity: i64, _decorated: Option<bool>) -> Self { - todo!() + /// `$verbosity` defaults to `self::VERBOSITY_NORMAL`; pass `None` to use it. + pub fn new( + stream: shirabe_php_shim::PhpResource, + verbosity: Option<i64>, + decorated: Option<bool>, + formatter: Option<std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>>, + ) -> anyhow::Result<Result<Self, InvalidArgumentException>> { + let verbosity = verbosity.unwrap_or(VERBOSITY_NORMAL); + + if !shirabe_php_shim::is_resource_value(&stream) + || "stream" != shirabe_php_shim::get_resource_type(&stream) + { + return Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "The StreamOutput class needs a stream as its first argument." + .to_string(), + code: 0, + }, + ))); + } + + let decorated = match decorated { + None => Some(Self::has_color_support(&stream)), + other => other, + }; + + let inner = Output::new(Some(verbosity), decorated.unwrap_or(false), formatter); + + Ok(Ok(Self { inner, stream })) + } + + pub(crate) fn inner(&self) -> &Output { + &self.inner + } + + /// Gets the stream attached to this StreamOutput instance. + pub fn get_stream(&self) -> &shirabe_php_shim::PhpResource { + &self.stream + } + + /// Returns true if the stream supports colorization. + /// + /// Colorization is disabled if not supported by the stream: + /// + /// This is tricky on Windows, because Cygwin, Msys2 etc emulate pseudo + /// terminals via named pipes, so we can only check the environment. + /// + /// Reference: Composer\XdebugHandler\Process::supportsColor + /// https://github.com/composer/xdebug-handler + pub(crate) fn has_color_support(stream: &shirabe_php_shim::PhpResource) -> bool { + // Follow https://no-color.org/ + if "" != no_color_first_char() { + return false; + } + + // Detect msysgit/mingw and assume this is a tty because detection + // does not work correctly, see https://github.com/composer/composer/issues/9690 + if !shirabe_php_shim::stream_isatty_resource(stream) + && !["MINGW32", "MINGW64"].contains( + &shirabe_php_shim::strtoupper( + &shirabe_php_shim::getenv("MSYSTEM").unwrap_or_default(), + ) + .as_str(), + ) + { + return false; + } + + if "\\" == shirabe_php_shim::DIRECTORY_SEPARATOR + && shirabe_php_shim::sapi_windows_vt100_support(stream) + { + return true; + } + + if Some("Hyper".to_string()) == shirabe_php_shim::getenv("TERM_PROGRAM") + || shirabe_php_shim::getenv("COLORTERM").is_some() + || shirabe_php_shim::getenv("ANSICON").is_some() + || Some("ON".to_string()) == shirabe_php_shim::getenv("ConEmuANSI") + { + return true; + } + + let term = shirabe_php_shim::getenv("TERM").unwrap_or_default(); + if "dumb" == term { + return false; + } + + // See https://github.com/chalk/supports-color/blob/d4f413efaf8da045c5ab440ed418ef02dbb28bf1/index.js#L157 + let mut matches: Vec<Option<String>> = Vec::new(); + shirabe_php_shim::preg_match( + "/^((screen|xterm|vt100|vt220|putty|rxvt|ansi|cygwin|linux).*)|(.*-256(color)?(-bce)?)$/", + &term, + &mut matches, + ) != 0 + } +} + +/// PHP: `(($_SERVER['NO_COLOR'] ?? getenv('NO_COLOR'))[0] ?? '')`. +fn no_color_first_char() -> String { + let value = shirabe_php_shim::getenv("NO_COLOR").unwrap_or_default(); + value + .chars() + .next() + .map(|c| c.to_string()) + .unwrap_or_default() +} + +impl DoWrite for StreamOutput { + fn do_write(&self, message: &str, newline: bool) { + let mut message = message.to_string(); + if newline { + message.push_str(shirabe_php_shim::PHP_EOL); + } + + shirabe_php_shim::fwrite_resource(&self.stream, &message); + + shirabe_php_shim::fflush_resource(&self.stream); } } impl OutputInterface for StreamOutput { - fn write(&self, _messages: &str, _newline: bool, _type: i64) { - todo!() + fn write(&self, messages: &[String], newline: bool, options: i64) { + self.inner.write(self, messages, newline, options); } - fn writeln(&self, _messages: &str, _type: i64) { - todo!() + fn writeln(&self, messages: &[String], options: i64) { + self.inner.writeln(self, messages, options); } - fn set_verbosity(&self, _level: i64) { - todo!() + fn set_verbosity(&self, level: i64) { + self.inner.set_verbosity(level); } fn get_verbosity(&self) -> i64 { - todo!() + self.inner.get_verbosity() } fn is_quiet(&self) -> bool { - todo!() + self.inner.is_quiet() } fn is_verbose(&self) -> bool { - todo!() + self.inner.is_verbose() } fn is_very_verbose(&self) -> bool { - todo!() + self.inner.is_very_verbose() } fn is_debug(&self) -> bool { - todo!() + self.inner.is_debug() } - fn set_decorated(&self, _decorated: bool) { - todo!() + fn set_decorated(&self, decorated: bool) { + self.inner.set_decorated(decorated); } fn is_decorated(&self) -> bool { - todo!() + self.inner.is_decorated() } fn set_formatter( &self, - _formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, + formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, ) { - todo!() + self.inner.set_formatter(formatter); } fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>> { - todo!() - } - fn get_stream(&self) -> shirabe_php_shim::PhpResource { - todo!() + self.inner.get_formatter() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/output/trimmed_buffer_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/trimmed_buffer_output.rs new file mode 100644 index 0000000..e603999 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/output/trimmed_buffer_output.rs @@ -0,0 +1,108 @@ +use crate::symfony::console::exception::InvalidArgumentException; +use crate::symfony::console::formatter::OutputFormatterInterface; +use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::output::{DoWrite, Output}; + +/// A BufferedOutput that keeps only the last N chars. +#[derive(Debug)] +pub struct TrimmedBufferOutput { + inner: Output, + max_length: i64, + buffer: std::cell::RefCell<String>, +} + +impl TrimmedBufferOutput { + pub fn new( + max_length: i64, + verbosity: Option<i64>, + decorated: bool, + formatter: Option<std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>>, + ) -> Result<Self, InvalidArgumentException> { + if max_length <= 0 { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "\"%s()\" expects a strictly positive maxLength. Got %d.", + &[ + shirabe_php_shim::PhpMixed::String( + "Symfony\\Component\\Console\\Output\\TrimmedBufferOutput::__construct" + .to_string(), + ), + shirabe_php_shim::PhpMixed::Int(max_length), + ], + ), + code: 0, + }, + )); + } + + Ok(Self { + inner: Output::new(verbosity, decorated, formatter), + max_length, + buffer: std::cell::RefCell::new(String::new()), + }) + } + + /// Empties buffer and returns its content. + pub fn fetch(&self) -> String { + let content = self.buffer.borrow().clone(); + *self.buffer.borrow_mut() = String::new(); + + content + } +} + +impl DoWrite for TrimmedBufferOutput { + fn do_write(&self, message: &str, newline: bool) { + self.buffer.borrow_mut().push_str(message); + + if newline { + self.buffer.borrow_mut().push_str(shirabe_php_shim::PHP_EOL); + } + + let trimmed = shirabe_php_shim::substr(&self.buffer.borrow(), 0 - self.max_length, None); + *self.buffer.borrow_mut() = trimmed; + } +} + +impl OutputInterface for TrimmedBufferOutput { + fn write(&self, messages: &[String], newline: bool, options: i64) { + self.inner.write(self, messages, newline, options); + } + fn writeln(&self, messages: &[String], options: i64) { + self.inner.writeln(self, messages, options); + } + fn set_verbosity(&self, level: i64) { + self.inner.set_verbosity(level); + } + fn get_verbosity(&self) -> i64 { + self.inner.get_verbosity() + } + fn is_quiet(&self) -> bool { + self.inner.is_quiet() + } + fn is_verbose(&self) -> bool { + self.inner.is_verbose() + } + fn is_very_verbose(&self) -> bool { + self.inner.is_very_verbose() + } + fn is_debug(&self) -> bool { + self.inner.is_debug() + } + fn set_decorated(&self, decorated: bool) { + self.inner.set_decorated(decorated); + } + fn is_decorated(&self) -> bool { + self.inner.is_decorated() + } + fn set_formatter( + &self, + formatter: std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>>, + ) { + self.inner.set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<dyn OutputFormatterInterface>> { + self.inner.get_formatter() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/question/choice_question.rs b/crates/shirabe-external-packages/src/symfony/console/question/choice_question.rs index 5eb5818..7ad29c8 100644 --- a/crates/shirabe-external-packages/src/symfony/console/question/choice_question.rs +++ b/crates/shirabe-external-packages/src/symfony/console/question/choice_question.rs @@ -1,19 +1,235 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; use crate::symfony::console::question::Question; +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +/// Represents a choice question. #[derive(Debug)] -pub struct ChoiceQuestion(pub Question); +pub struct ChoiceQuestion { + inner: Question, + choices: IndexMap<String, Box<PhpMixed>>, + multiselect: bool, + prompt: String, + error_message: String, +} impl ChoiceQuestion { - pub fn new(_question: &str, _choices: Vec<PhpMixed>, _default: Option<PhpMixed>) -> Self { - todo!() + /// `$question` The question to ask to the user. + /// `$choices` The list of available choices. + /// `$default` The default answer to return. + pub fn new( + question: String, + choices: IndexMap<String, Box<PhpMixed>>, + default: Option<PhpMixed>, + ) -> Result<Self, LogicException> { + if choices.is_empty() { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "Choice question must have at least 1 choice available.".to_string(), + code: 0, + })); + } + + let mut this = Self { + inner: Question::new(question, default), + choices: choices.clone(), + multiselect: false, + prompt: " > ".to_string(), + error_message: "Value \"%s\" is invalid".to_string(), + }; + + let validator = this.get_default_validator(); + this.inner.set_validator(Some(validator)); + // setAutocompleterValues never throws for an array argument. + this.inner + .set_autocompleter_values(Some(PhpMixed::Array(choices))) + .expect("autocompleter cannot be set on a hidden question during construction"); + + Ok(this) + } + + /// Returns available choices. + pub fn get_choices(&self) -> &IndexMap<String, Box<PhpMixed>> { + &self.choices + } + + /// Sets multiselect option. + /// + /// When multiselect is set to true, multiple choices can be answered. + pub fn set_multiselect(&mut self, multiselect: bool) -> &mut Self { + self.multiselect = multiselect; + let validator = self.get_default_validator(); + self.inner.set_validator(Some(validator)); + + self + } + + /// Returns whether the choices are multiselect. + pub fn is_multiselect(&self) -> bool { + self.multiselect + } + + /// Gets the prompt for choices. + pub fn get_prompt(&self) -> &str { + &self.prompt } - pub fn set_multiselect(&mut self, _multiselect: bool) { - todo!() + /// Sets the prompt for choices. + pub fn set_prompt(&mut self, prompt: String) -> &mut Self { + self.prompt = prompt; + + self } - pub fn set_error_message(&mut self, _error_message: &str) { - todo!() + /// Sets the error message for invalid values. + /// + /// The error message has a string placeholder (%s) for the invalid value. + pub fn set_error_message(&mut self, error_message: String) -> &mut Self { + self.error_message = error_message; + let validator = self.get_default_validator(); + self.inner.set_validator(Some(validator)); + + self } + + fn get_default_validator( + &self, + ) -> Box<dyn Fn(Option<PhpMixed>) -> Result<PhpMixed, InvalidArgumentException>> { + let choices = self.choices.clone(); + let error_message = self.error_message.clone(); + let multiselect = self.multiselect; + let is_assoc = Question::is_assoc(&PhpMixed::Array(self.choices.clone())); + // PHP reads `$this->isTrimmable()` live inside the closure. A 'static boxed + // closure cannot borrow `$this`, so the value is snapshotted at validator + // creation time. setValidator is re-run on multiselect/errorMessage changes, + // but a later setTrimmable would not be reflected. See review notes. + let trimmable = self.inner.is_trimmable(); + + Box::new(move |selected: Option<PhpMixed>| { + let selected = selected.unwrap_or(PhpMixed::Null); + + let selected_choices: Vec<PhpMixed> = if multiselect { + // Check for a separated comma values + let mut matches: Vec<Option<String>> = Vec::new(); + if shirabe_php_shim::preg_match( + "/^[^,]+(?:,[^,]+)*$/", + &shirabe_php_shim::strval(&selected), + &mut matches, + ) == 0 + { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf(&error_message, &[selected.clone()]), + code: 0, + }, + )); + } + + shirabe_php_shim::explode(",", &shirabe_php_shim::strval(&selected)) + .into_iter() + .map(PhpMixed::String) + .collect() + } else { + vec![selected.clone()] + }; + + let mut selected_choices = selected_choices; + if trimmable { + for v in selected_choices.iter_mut() { + *v = PhpMixed::String(shirabe_php_shim::trim( + &shirabe_php_shim::strval(v), + None, + )); + } + } + + let mut multiselect_choices: Vec<PhpMixed> = Vec::new(); + for value in &selected_choices { + let mut results: Vec<String> = Vec::new(); + for (key, choice) in &choices { + if (**choice) == *value { + results.push(key.clone()); + } + } + + if results.len() > 1 { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The provided answer is ambiguous. Value should be one of \"%s\".", + &[PhpMixed::String(shirabe_php_shim::implode( + "\" or \"", &results, + ))], + ), + code: 0, + }, + )); + } + + // array_search($value, $choices) + let result_key = shirabe_php_shim::array_search( + &shirabe_php_shim::strval(value), + &choices_as_str(&choices), + ); + + let mut result: PhpMixed; + if !is_assoc { + if let Some(found_key) = &result_key { + // $result = $choices[$result]; + result = (*choices[found_key]).clone(); + } else if let Some(found) = choices.get(&shirabe_php_shim::strval(value)) { + // isset($choices[$value]) + result = (**found).clone(); + } else { + result = PhpMixed::Bool(false); + } + } else if result_key.is_none() { + if let Some(_found) = choices.get(&shirabe_php_shim::strval(value)) { + // false === $result && isset($choices[$value]) + result = value.clone(); + } else { + result = PhpMixed::Bool(false); + } + } else { + // associative, found: keep the matched key + result = PhpMixed::String(result_key.clone().unwrap()); + } + + // false === $result + if matches!(result, PhpMixed::Bool(false)) { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf(&error_message, &[value.clone()]), + code: 0, + }, + )); + } + + // For associative choices, consistently return the key as string: + if is_assoc { + result = PhpMixed::String(shirabe_php_shim::strval(&result)); + } + multiselect_choices.push(result); + } + + if multiselect { + return Ok(PhpMixed::List( + multiselect_choices.into_iter().map(Box::new).collect(), + )); + } + + Ok(shirabe_php_shim::current(PhpMixed::List( + multiselect_choices.into_iter().map(Box::new).collect(), + ))) + }) + } +} + +/// array_search operates over the choice values as strings; this projects the +/// choices map's values into the string-keyed form the shim expects. +fn choices_as_str(choices: &IndexMap<String, Box<PhpMixed>>) -> IndexMap<String, String> { + choices + .iter() + .map(|(k, v)| (k.clone(), shirabe_php_shim::strval(v))) + .collect() } diff --git a/crates/shirabe-external-packages/src/symfony/console/question/confirmation_question.rs b/crates/shirabe-external-packages/src/symfony/console/question/confirmation_question.rs new file mode 100644 index 0000000..838ec45 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/question/confirmation_question.rs @@ -0,0 +1,57 @@ +use crate::symfony::console::question::Question; +use shirabe_php_shim::PhpMixed; + +/// Represents a yes/no question. +#[derive(Debug)] +pub struct ConfirmationQuestion { + inner: Question, + true_answer_regex: String, +} + +impl ConfirmationQuestion { + /// `$question` The question to ask to the user. + /// `$default` The default answer to return, true or false. + /// `$trueAnswerRegex` A regex to match the "yes" answer. + pub fn new(question: String, default: bool, true_answer_regex: String) -> Self { + let mut this = Self { + inner: Question::new(question, Some(PhpMixed::Bool(default))), + true_answer_regex, + }; + + let normalizer = this.get_default_normalizer(); + this.inner.set_normalizer(normalizer); + + this + } + + /// Returns the default answer normalizer. + fn get_default_normalizer(&self) -> Box<dyn Fn(PhpMixed) -> PhpMixed> { + let default = self.inner.get_default(); + let regex = self.true_answer_regex.clone(); + + Box::new(move |answer: PhpMixed| { + if let PhpMixed::Bool(_) = answer { + return answer; + } + + let answer_is_true = { + let mut matches: Vec<Option<String>> = Vec::new(); + shirabe_php_shim::preg_match( + ®ex, + &shirabe_php_shim::strval(&answer), + &mut matches, + ) != 0 + }; + + // false === $default + if matches!(default, PhpMixed::Bool(false)) { + // $answer && $answerIsTrue + return PhpMixed::Bool(!shirabe_php_shim::empty(&answer) && answer_is_true); + } + + // '' === $answer || $answerIsTrue + let answer_is_empty_string = matches!(&answer, PhpMixed::String(s) if s.is_empty()); + PhpMixed::Bool(answer_is_empty_string || answer_is_true) + }) + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/question/mod.rs b/crates/shirabe-external-packages/src/symfony/console/question/mod.rs index 67e7e7b..06e0c2c 100644 --- a/crates/shirabe-external-packages/src/symfony/console/question/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/question/mod.rs @@ -1,5 +1,7 @@ pub mod choice_question; +pub mod confirmation_question; pub mod question; pub use choice_question::*; +pub use confirmation_question::*; pub use question::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/question/question.rs b/crates/shirabe-external-packages/src/symfony/console/question/question.rs index 552f780..754f286 100644 --- a/crates/shirabe-external-packages/src/symfony/console/question/question.rs +++ b/crates/shirabe-external-packages/src/symfony/console/question/question.rs @@ -1,53 +1,275 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; use shirabe_php_shim::PhpMixed; -#[derive(Debug)] -pub struct Question; +/// Represents a Question. +pub struct Question { + question: String, + attempts: Option<i64>, + hidden: bool, + hidden_fallback: bool, + autocompleter_callback: Option<Box<dyn Fn(&str) -> Option<Vec<PhpMixed>>>>, + validator: Option<Box<dyn Fn(Option<PhpMixed>) -> Result<PhpMixed, InvalidArgumentException>>>, + default: Option<PhpMixed>, + normalizer: Option<Box<dyn Fn(PhpMixed) -> PhpMixed>>, + trimmable: bool, + multiline: bool, +} + +impl std::fmt::Debug for Question { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Question") + .field("question", &self.question) + .field("attempts", &self.attempts) + .field("hidden", &self.hidden) + .field("hidden_fallback", &self.hidden_fallback) + .field("default", &self.default) + .field("trimmable", &self.trimmable) + .field("multiline", &self.multiline) + .finish_non_exhaustive() + } +} impl Question { - pub fn new(_question: &str, _default: Option<PhpMixed>) -> Self { - todo!() + /// `$question` The question to ask to the user. + /// `$default` The default answer to return if the user enters nothing. + pub fn new(question: String, default: Option<PhpMixed>) -> Self { + Self { + question, + attempts: None, + hidden: false, + hidden_fallback: true, + autocompleter_callback: None, + validator: None, + default, + normalizer: None, + trimmable: true, + multiline: false, + } } - pub fn set_validator( - &mut self, - _validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, - ) { - todo!() + /// Returns the question. + pub fn get_question(&self) -> &str { + &self.question } - pub fn set_max_attempts(&mut self, _attempts: Option<i64>) { - todo!() + /// Returns the default answer. + pub fn get_default(&self) -> PhpMixed { + self.default.clone().unwrap_or(PhpMixed::Null) } - pub fn set_normalizer(&mut self, _normalizer: Box<dyn Fn(&PhpMixed) -> PhpMixed>) { - todo!() + /// Returns whether the user response accepts newline characters. + pub fn is_multiline(&self) -> bool { + self.multiline } - pub fn set_hidden(&mut self, _hidden: bool) { - todo!() + /// Sets whether the user response should accept newline characters. + pub fn set_multiline(&mut self, multiline: bool) -> &mut Self { + self.multiline = multiline; + + self } - pub fn set_hidden_fallback(&mut self, _fallback: bool) { - todo!() + /// Returns whether the user response must be hidden. + pub fn is_hidden(&self) -> bool { + self.hidden } - pub fn get_question(&self) -> String { - todo!() + /// Sets whether the user response must be hidden or not. + /// + /// Throws LogicException in case the autocompleter is also used. + pub fn set_hidden(&mut self, hidden: bool) -> Result<&mut Self, LogicException> { + if self.autocompleter_callback.is_some() { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "A hidden question cannot use the autocompleter.".to_string(), + code: 0, + })); + } + + self.hidden = hidden; + + Ok(self) } - pub fn get_default(&self) -> Option<PhpMixed> { - todo!() + /// In case the response cannot be hidden, whether to fallback on non-hidden question or not. + pub fn is_hidden_fallback(&self) -> bool { + self.hidden_fallback } - pub fn is_hidden(&self) -> bool { - todo!() + /// Sets whether to fallback on non-hidden question if the response cannot be hidden. + pub fn set_hidden_fallback(&mut self, fallback: bool) -> &mut Self { + self.hidden_fallback = fallback; + + self } - pub fn get_validator(&self) -> Option<&dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>> { - todo!() + /// Gets values for the autocompleter. + pub fn get_autocompleter_values(&self) -> Option<Vec<PhpMixed>> { + let callback = self.get_autocompleter_callback(); + + match callback { + Some(callback) => callback(""), + None => None, + } + } + + /// Sets values for the autocompleter. + /// + /// Throws LogicException. + pub fn set_autocompleter_values( + &mut self, + values: Option<PhpMixed>, + ) -> Result<&mut Self, LogicException> { + let callback: Option<Box<dyn Fn(&str) -> Option<Vec<PhpMixed>>>> = match values { + // PHP: `if (\is_array($values))`. Both PhpMixed::List and ::Array model PHP arrays. + Some(values) if matches!(values, PhpMixed::List(_) | PhpMixed::Array(_)) => { + let values = if Self::is_assoc(&values) { + let array = match &values { + PhpMixed::Array(array) => array, + _ => unreachable!(), + }; + // array_merge(array_keys($values), array_values($values)) + let mut merged: Vec<PhpMixed> = + array.keys().map(|k| PhpMixed::String(k.clone())).collect(); + merged.extend(array.values().map(|v| (**v).clone())); + merged + } else { + // array_values($values) + match &values { + PhpMixed::List(list) => list.iter().map(|v| (**v).clone()).collect(), + PhpMixed::Array(array) => array.values().map(|v| (**v).clone()).collect(), + _ => unreachable!(), + } + }; + + Some(Box::new(move |_input: &str| Some(values.clone()))) + } + // PHP: `elseif ($values instanceof \Traversable)`. In PHP this caches the + // iterator result; here non-array iterables are not modeled, so treat any + // remaining value as the Traversable branch. + Some(values) => { + // PHP: `iterator_to_array($values, false)` caches a Traversable. + // Non-array iterables are not modeled by PhpMixed; extract any + // list/array elements, otherwise treat as an empty iterator. + let cached: Vec<PhpMixed> = match values { + PhpMixed::List(list) => list.into_iter().map(|v| *v).collect(), + PhpMixed::Array(array) => array.into_values().map(|v| *v).collect(), + _ => Vec::new(), + }; + Some(Box::new(move |_input: &str| Some(cached.clone()))) + } + None => None, + }; + + self.set_autocompleter_callback(callback) + } + + /// Gets the callback function used for the autocompleter. + pub fn get_autocompleter_callback(&self) -> Option<&(dyn Fn(&str) -> Option<Vec<PhpMixed>>)> { + self.autocompleter_callback.as_deref() + } + + /// Sets the callback function used for the autocompleter. + /// + /// The callback is passed the user input as argument and should return an iterable of + /// corresponding suggestions. + pub fn set_autocompleter_callback( + &mut self, + callback: Option<Box<dyn Fn(&str) -> Option<Vec<PhpMixed>>>>, + ) -> Result<&mut Self, LogicException> { + if self.hidden && callback.is_some() { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "A hidden question cannot use the autocompleter.".to_string(), + code: 0, + })); + } + + self.autocompleter_callback = callback; + + Ok(self) } + /// Sets a validator for the question. + pub fn set_validator( + &mut self, + validator: Option< + Box<dyn Fn(Option<PhpMixed>) -> Result<PhpMixed, InvalidArgumentException>>, + >, + ) -> &mut Self { + self.validator = validator; + + self + } + + /// Gets the validator for the question. + pub fn get_validator( + &self, + ) -> Option<&(dyn Fn(Option<PhpMixed>) -> Result<PhpMixed, InvalidArgumentException>)> { + self.validator.as_deref() + } + + /// Sets the maximum number of attempts. + /// + /// Null means an unlimited number of attempts. + /// + /// Throws InvalidArgumentException in case the number of attempts is invalid. + pub fn set_max_attempts( + &mut self, + attempts: Option<i64>, + ) -> Result<&mut Self, InvalidArgumentException> { + if let Some(attempts) = attempts { + if attempts < 1 { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "Maximum number of attempts must be a positive value.".to_string(), + code: 0, + }, + )); + } + } + + self.attempts = attempts; + + Ok(self) + } + + /// Gets the maximum number of attempts. + /// + /// Null means an unlimited number of attempts. pub fn get_max_attempts(&self) -> Option<i64> { - todo!() + self.attempts + } + + /// Sets a normalizer for the response. + /// + /// The normalizer can be a callable (a string), a closure or a class implementing __invoke. + pub fn set_normalizer(&mut self, normalizer: Box<dyn Fn(PhpMixed) -> PhpMixed>) -> &mut Self { + self.normalizer = Some(normalizer); + + self + } + + /// Gets the normalizer for the response. + /// + /// The normalizer can ba a callable (a string), a closure or a class implementing __invoke. + pub fn get_normalizer(&self) -> Option<&(dyn Fn(PhpMixed) -> PhpMixed)> { + self.normalizer.as_deref() + } + + // PHP: `(bool) \count(array_filter(array_keys($array), 'is_string'))`. + // PhpMixed models a PHP array as either `List` (sequential int keys) or `Array` + // (string-keyed map), so the "has string keys" test reduces to the `Array` variant. + pub(crate) fn is_assoc(array: &PhpMixed) -> bool { + matches!(array, PhpMixed::Array(_)) + } + + pub fn is_trimmable(&self) -> bool { + self.trimmable + } + + pub fn set_trimmable(&mut self, trimmable: bool) -> &mut Self { + self.trimmable = trimmable; + + self } } diff --git a/crates/shirabe-external-packages/src/symfony/console/signal_registry/mod.rs b/crates/shirabe-external-packages/src/symfony/console/signal_registry/mod.rs new file mode 100644 index 0000000..a07ca01 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/signal_registry/mod.rs @@ -0,0 +1,3 @@ +pub mod signal_registry; + +pub use signal_registry::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/signal_registry/signal_registry.rs b/crates/shirabe-external-packages/src/symfony/console/signal_registry/signal_registry.rs new file mode 100644 index 0000000..bd6b989 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/signal_registry/signal_registry.rs @@ -0,0 +1,100 @@ +use indexmap::IndexMap; + +/// A signal handler receives the signal number and whether a further handler follows. +pub type SignalHandler = Box<dyn Fn(i64, bool)>; + +pub struct SignalRegistry { + // signal number => list of handlers + signal_handlers: IndexMap<i64, Vec<SignalHandler>>, +} + +impl std::fmt::Debug for SignalRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SignalRegistry") + .field("signal_handlers", &self.signal_handlers.keys()) + .finish_non_exhaustive() + } +} + +impl Default for SignalRegistry { + fn default() -> Self { + Self::new() + } +} + +impl SignalRegistry { + pub fn new() -> Self { + if shirabe_php_shim::function_exists("pcntl_async_signals") { + shirabe_php_shim::pcntl_async_signals(true); + } + + Self { + signal_handlers: IndexMap::new(), + } + } + + pub fn register(&mut self, signal: i64, signal_handler: SignalHandler) { + if !self.signal_handlers.contains_key(&signal) { + let previous_callback = shirabe_php_shim::pcntl_signal_get_handler(signal); + + if shirabe_php_shim::is_callable(&previous_callback) { + // $this->signalHandlers[$signal][] = $previousCallback; + // The previous handler is an opaque PHP callable obtained from pcntl; + // it is invoked through the runtime callable mechanism. + self.signal_handlers + .entry(signal) + .or_default() + .push(Box::new(move |signal, has_next| { + shirabe_php_shim::call_php_callable( + &previous_callback, + &[ + shirabe_php_shim::PhpMixed::Int(signal), + shirabe_php_shim::PhpMixed::Bool(has_next), + ], + ); + })); + } + } + + self.signal_handlers + .entry(signal) + .or_default() + .push(signal_handler); + + // pcntl_signal($signal, [$this, 'handle']) + // TODO(plugin): the PHP callback `[$this, 'handle']` captures the registry + // instance. Wiring this object method as a C-level signal handler requires the + // runtime callable mechanism; see review notes. + shirabe_php_shim::pcntl_signal(signal, shirabe_php_shim::PhpMixed::Null); + } + + pub fn is_supported() -> bool { + if !shirabe_php_shim::function_exists("pcntl_signal") { + return false; + } + + if shirabe_php_shim::explode( + ",", + &shirabe_php_shim::ini_get("disable_functions").unwrap_or_default(), + ) + .contains(&"pcntl_signal".to_string()) + { + return false; + } + + true + } + + pub fn handle(&self, signal: i64) { + let handlers = match self.signal_handlers.get(&signal) { + Some(handlers) => handlers, + None => return, + }; + let count = handlers.len(); + + for (i, signal_handler) in handlers.iter().enumerate() { + let has_next = i != count - 1; + signal_handler(signal, has_next); + } + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/single_command_application.rs b/crates/shirabe-external-packages/src/symfony/console/single_command_application.rs deleted file mode 100644 index 9273477..0000000 --- a/crates/shirabe-external-packages/src/symfony/console/single_command_application.rs +++ /dev/null @@ -1,33 +0,0 @@ -#[derive(Debug)] -pub struct SingleCommandApplication; - -impl Default for SingleCommandApplication { - fn default() -> Self { - Self::new() - } -} - -impl SingleCommandApplication { - pub fn new() -> Self { - todo!() - } - - pub fn set_name(&mut self, _name: &str) -> &mut Self { - todo!() - } - - pub fn set_version(&mut self, _version: &str) -> &mut Self { - todo!() - } - - pub fn set_code( - &mut self, - _code: Box<dyn Fn(&dyn std::any::Any, &dyn std::any::Any) -> i64>, - ) -> &mut Self { - todo!() - } - - pub fn run(&mut self) -> anyhow::Result<i64> { - todo!() - } -} diff --git a/crates/shirabe-external-packages/src/symfony/console/style/mod.rs b/crates/shirabe-external-packages/src/symfony/console/style/mod.rs index e002b05..efc54e1 100644 --- a/crates/shirabe-external-packages/src/symfony/console/style/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/style/mod.rs @@ -1,3 +1,7 @@ +pub mod output_style; +pub mod style_interface; pub mod symfony_style; +pub use output_style::*; +pub use style_interface::*; pub use symfony_style::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/style/output_style.rs b/crates/shirabe-external-packages/src/symfony/console/style/output_style.rs new file mode 100644 index 0000000..6286e01 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/style/output_style.rs @@ -0,0 +1,193 @@ +use crate::symfony::console::formatter::OutputFormatterInterface; +use crate::symfony::console::helper::ProgressBar; +use crate::symfony::console::output::ConsoleOutputInterface; +use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::output_interface::OUTPUT_NORMAL; +use crate::symfony::console::style::style_interface::StyleInterface; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; + +/// Decorates output to add console style guide helpers. +#[derive(Debug)] +pub struct OutputStyle { + output: Rc<RefCell<dyn OutputInterface>>, +} + +impl OutputStyle { + pub fn new(output: Rc<RefCell<dyn OutputInterface>>) -> Self { + Self { output } + } + + pub fn create_progress_bar(&self, max: i64) -> ProgressBar { + ProgressBar::new(self.output.clone(), max, 1.0 / 25.0) + } + + pub(crate) fn get_error_output(&self) -> Rc<RefCell<dyn OutputInterface>> { + // PHP checks `$this->output instanceof ConsoleOutputInterface`; this requires + // runtime type information that the OutputInterface trait object lacks. + if !Self::is_console_output_interface(&self.output) { + return self.output.clone(); + } + + Self::as_console_output_interface(&self.output) + .unwrap() + .borrow() + .get_error_output() + } + + fn is_console_output_interface(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { + todo!() + } + + fn as_console_output_interface( + _output: &Rc<RefCell<dyn OutputInterface>>, + ) -> Option<Rc<RefCell<dyn ConsoleOutputInterface>>> { + todo!() + } +} + +impl OutputInterface for OutputStyle { + fn write(&self, messages: &[String], newline: bool, options: i64) { + self.output.borrow().write(messages, newline, options); + } + + fn writeln(&self, messages: &[String], options: i64) { + self.output.borrow().writeln(messages, options); + } + + fn set_verbosity(&self, level: i64) { + self.output.borrow().set_verbosity(level); + } + + fn get_verbosity(&self) -> i64 { + self.output.borrow().get_verbosity() + } + + fn is_quiet(&self) -> bool { + self.output.borrow().is_quiet() + } + + fn is_verbose(&self) -> bool { + self.output.borrow().is_verbose() + } + + fn is_very_verbose(&self) -> bool { + self.output.borrow().is_very_verbose() + } + + fn is_debug(&self) -> bool { + self.output.borrow().is_debug() + } + + fn set_decorated(&self, decorated: bool) { + self.output.borrow().set_decorated(decorated); + } + + fn is_decorated(&self) -> bool { + self.output.borrow().is_decorated() + } + + fn set_formatter(&self, formatter: Rc<RefCell<dyn OutputFormatterInterface>>) { + self.output.borrow().set_formatter(formatter); + } + + fn get_formatter(&self) -> Rc<RefCell<dyn OutputFormatterInterface>> { + self.output.borrow().get_formatter() + } +} + +impl StyleInterface for OutputStyle { + fn title(&mut self, _message: &str) { + todo!() + } + + fn section(&mut self, _message: &str) { + todo!() + } + + fn listing(&mut self, _elements: Vec<PhpMixed>) { + todo!() + } + + fn text(&mut self, _message: PhpMixed) { + todo!() + } + + fn success(&mut self, _message: PhpMixed) { + todo!() + } + + fn error(&mut self, _message: PhpMixed) { + todo!() + } + + fn warning(&mut self, _message: PhpMixed) { + todo!() + } + + fn note(&mut self, _message: PhpMixed) { + todo!() + } + + fn caution(&mut self, _message: PhpMixed) { + todo!() + } + + fn table(&mut self, _headers: Vec<PhpMixed>, _rows: Vec<PhpMixed>) { + todo!() + } + + fn ask( + &mut self, + _question: &str, + _default: Option<&str>, + _validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, + ) -> PhpMixed { + todo!() + } + + fn ask_hidden( + &mut self, + _question: &str, + _validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, + ) -> PhpMixed { + todo!() + } + + fn confirm(&mut self, _question: &str, _default: bool) -> bool { + todo!() + } + + fn choice( + &mut self, + _question: &str, + _choices: Vec<PhpMixed>, + _default: Option<PhpMixed>, + ) -> PhpMixed { + todo!() + } + + fn new_line(&mut self, count: i64) { + self.output.borrow().write( + &[shirabe_php_shim::str_repeat( + shirabe_php_shim::PHP_EOL, + count as usize, + )], + false, + OUTPUT_NORMAL, + ); + } + + fn progress_start(&mut self, _max: i64) { + todo!() + } + + fn progress_advance(&mut self, _step: i64) { + todo!() + } + + fn progress_finish(&mut self) { + todo!() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/style/style_interface.rs b/crates/shirabe-external-packages/src/symfony/console/style/style_interface.rs new file mode 100644 index 0000000..c94e450 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/style/style_interface.rs @@ -0,0 +1,72 @@ +use shirabe_php_shim::PhpMixed; + +/// Output style helpers. +pub trait StyleInterface { + /// Formats a command title. + fn title(&mut self, message: &str); + + /// Formats a section title. + fn section(&mut self, message: &str); + + /// Formats a list. + fn listing(&mut self, elements: Vec<PhpMixed>); + + /// Formats informational text. + fn text(&mut self, message: PhpMixed); + + /// Formats a success result bar. + fn success(&mut self, message: PhpMixed); + + /// Formats an error result bar. + fn error(&mut self, message: PhpMixed); + + /// Formats an warning result bar. + fn warning(&mut self, message: PhpMixed); + + /// Formats a note admonition. + fn note(&mut self, message: PhpMixed); + + /// Formats a caution admonition. + fn caution(&mut self, message: PhpMixed); + + /// Formats a table. + fn table(&mut self, headers: Vec<PhpMixed>, rows: Vec<PhpMixed>); + + /// Asks a question. + fn ask( + &mut self, + question: &str, + default: Option<&str>, + validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, + ) -> PhpMixed; + + /// Asks a question with the user input hidden. + fn ask_hidden( + &mut self, + question: &str, + validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, + ) -> PhpMixed; + + /// Asks for confirmation. + fn confirm(&mut self, question: &str, default: bool) -> bool; + + /// Asks a choice question. + fn choice( + &mut self, + question: &str, + choices: Vec<PhpMixed>, + default: Option<PhpMixed>, + ) -> PhpMixed; + + /// Add newline(s). + fn new_line(&mut self, count: i64); + + /// Starts the progress output. + fn progress_start(&mut self, max: i64); + + /// Advances the progress output X steps. + fn progress_advance(&mut self, step: i64); + + /// Finishes the progress output. + fn progress_finish(&mut self); +} diff --git a/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs b/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs index c26ca30..72579fc 100644 --- a/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs +++ b/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs @@ -1,113 +1,759 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::formatter::OutputFormatter; +use crate::symfony::console::formatter::OutputFormatterInterface; +use crate::symfony::console::helper::Helper; +use crate::symfony::console::helper::ProgressBar; +use crate::symfony::console::helper::SymfonyQuestionHelper; +use crate::symfony::console::helper::Table; +use crate::symfony::console::helper::TableCell; +use crate::symfony::console::helper::TableSeparator; use crate::symfony::console::input::InputInterface; +use crate::symfony::console::output::ConsoleOutputInterface; use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::TrimmedBufferOutput; +use crate::symfony::console::output::output_interface::OUTPUT_NORMAL; +use crate::symfony::console::question::ChoiceQuestion; +use crate::symfony::console::question::ConfirmationQuestion; +use crate::symfony::console::question::Question; +use crate::symfony::console::style::output_style::OutputStyle; +use crate::symfony::console::style::style_interface::StyleInterface; +use crate::symfony::console::terminal::Terminal; use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; +/// Output decorator helpers for the Symfony Style Guide. #[derive(Debug)] -pub struct SymfonyStyle; +pub struct SymfonyStyle { + inner: OutputStyle, + input: Rc<RefCell<dyn InputInterface>>, + output: Rc<RefCell<dyn OutputInterface>>, + question_helper: Option<SymfonyQuestionHelper>, + progress_bar: Option<ProgressBar>, + line_length: i64, + buffered_output: TrimmedBufferOutput, +} + +pub const MAX_LINE_LENGTH: i64 = 120; impl SymfonyStyle { pub fn new( - _input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, - _output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, + input: Rc<RefCell<dyn InputInterface>>, + output: Rc<RefCell<dyn OutputInterface>>, ) -> Self { - todo!() + let buffered_output = TrimmedBufferOutput::new( + if std::path::MAIN_SEPARATOR == '\\' { + 4 + } else { + 2 + }, + Some(output.borrow().get_verbosity()), + false, + // TODO(plugin): clone of the formatter; PHP `clone $output->getFormatter()`. + Some(output.borrow().get_formatter()), + ) + .unwrap(); + // Windows cmd wraps lines as soon as the terminal width is reached, whether there are following chars or not. + let width = { + let w = Terminal::new().get_width(); + if w != 0 { w } else { MAX_LINE_LENGTH } + }; + let line_length = shirabe_php_shim::min( + width - (std::path::MAIN_SEPARATOR == '\\') as i64, + MAX_LINE_LENGTH, + ); + + let inner = OutputStyle::new(output.clone()); + + Self { + inner, + input, + output, + question_helper: None, + progress_bar: None, + line_length, + buffered_output, + } } - pub fn title(&mut self, _message: &str) { - todo!() + /// Formats a message as a block of text. + pub fn block( + &mut self, + messages: PhpMixed, + r#type: Option<&str>, + style: Option<&str>, + prefix: &str, + padding: bool, + escape: bool, + ) { + let messages: Vec<PhpMixed> = if shirabe_php_shim::is_array(&messages) { + // array_values($messages) + todo!() + } else { + vec![messages] + }; + + self.auto_prepend_block(); + let block = self.create_block(messages, r#type, style, prefix, padding, escape); + self.writeln( + PhpMixed::List( + block + .into_iter() + .map(|line| Box::new(PhpMixed::String(line))) + .collect(), + ), + OUTPUT_NORMAL, + ); + self.new_line(1); } - pub fn section(&mut self, _message: &str) { - todo!() + /// Formats a command comment. + pub fn comment(&mut self, message: PhpMixed) { + self.block( + message, + None, + None, + "<fg=default;bg=default> // </>", + false, + false, + ); } - pub fn text(&mut self, _message: &str) { - todo!() + /// Formats an info message. + pub fn info(&mut self, message: PhpMixed) { + self.block(message, Some("INFO"), Some("fg=green"), " ", true, true); } - pub fn comment(&mut self, _message: &str) { - todo!() + /// Formats a horizontal table. + pub fn horizontal_table(&mut self, headers: Vec<PhpMixed>, rows: Vec<PhpMixed>) { + self.create_table() + .set_horizontal(true) + .set_headers(headers) + .set_rows(rows) + .render(); + + self.new_line(1); } - pub fn success(&mut self, _message: PhpMixed) { - todo!() + /// Formats a list of key/value horizontally. + /// + /// Each row can be one of: + /// * 'A title' + /// * ['key' => 'value'] + /// * new TableSeparator() + pub fn definition_list(&mut self, list: Vec<PhpMixed>) { + let mut headers: Vec<PhpMixed> = Vec::new(); + let mut row: Vec<PhpMixed> = Vec::new(); + for value in list { + if Self::is_table_separator(&value) { + headers.push(value.clone()); + row.push(value); + continue; + } + if shirabe_php_shim::is_string(&value) { + // TODO: store a `TableCell` (with colspan => 2) into the mixed array. + let _table_cell = TableCell::new(&Self::php_string(&value), { + let mut options = indexmap::IndexMap::new(); + options.insert( + "colspan".to_string(), + crate::symfony::console::helper::table_cell::TableCellOption::Int(2), + ); + options + }); + let _ = _table_cell; + todo!(); + } + if !shirabe_php_shim::is_array(&value) { + // TODO(plugin): recoverable error path. + let _ = InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "Value should be an array, string, or an instance of TableSeparator." + .to_string(), + code: 0, + }); + todo!() + } + // $headers[] = key($value); $row[] = current($value); + let _ = shirabe_php_shim::key(value.clone()); + headers.push(todo!()); + row.push(shirabe_php_shim::current(value)); + } + + self.horizontal_table( + headers, + vec![PhpMixed::List(row.into_iter().map(Box::new).collect())], + ); } - pub fn error(&mut self, _message: PhpMixed) { + pub fn progress_iterate( + &mut self, + _iterable: Vec<PhpMixed>, + _max: Option<i64>, + ) -> Vec<PhpMixed> { + // yield from $this->createProgressBar()->iterate($iterable, $max); + // $this->newLine(2); todo!() } - pub fn warning(&mut self, _message: PhpMixed) { - todo!() + pub fn ask_question(&mut self, question: Question) -> PhpMixed { + if self.input.borrow().is_interactive() { + self.auto_prepend_block(); + } + + if self.question_helper.is_none() { + self.question_helper = Some(SymfonyQuestionHelper::new()); + } + + // TODO(plugin): pass `self` as the OutputInterface to the question helper. + let answer = { + let input = self.input.clone(); + let mut input = input.borrow_mut(); + self.question_helper + .as_mut() + .unwrap() + .ask(&mut *input, self.output.clone(), &question) + }; + // PHP `askQuestion` returns the answer directly; exceptions propagate. Phase B + // collapses the double `Result` by panicking on either error. + let answer = answer + .expect("question helper error") + .expect("missing input"); + + if self.input.borrow().is_interactive() { + self.new_line(1); + self.buffered_output + .write(&["\n".to_string()], false, OUTPUT_NORMAL); + } + + answer } - pub fn note(&mut self, _message: PhpMixed) { - todo!() + /// Returns a new instance which makes use of stderr if available. + pub fn get_error_style(&self) -> Self { + Self::new(self.input.clone(), self.inner.get_error_output()) } - pub fn listing(&mut self, _elements: &[String]) { - todo!() + pub fn create_table(&mut self) -> Table { + // TODO(plugin): ConsoleOutputInterface::section() requires runtime type info. + let output = if Self::is_console_output_interface(&self.output) { + Self::as_console_output_interface(&self.output) + .unwrap() + .borrow() + .section() as Rc<RefCell<dyn OutputInterface>> + } else { + self.output.clone() + }; + let mut style = Table::get_style_definition("symfony-style-guide".to_string()) + .expect("style definition lookup") + .expect("undefined style definition"); + style.set_cell_header_format("<info>%s</info>".to_string()); + + let mut table = Table::new(output); + // PHP passes the cloned `TableStyle` instance directly; `set_style` here takes a + // `PhpMixed` name/style. Phase B leaves the polymorphic style passing as a TODO. + let _ = &style; + let _ = table.set_style(PhpMixed::String("symfony-style-guide".to_string())); + table } - pub fn new_line(&mut self, _count: i64) { - todo!() + pub fn create_progress_bar(&self, max: i64) -> ProgressBar { + let mut progress_bar = self.inner.create_progress_bar(max); + + if std::path::MAIN_SEPARATOR != '\\' + || shirabe_php_shim::getenv("TERM_PROGRAM").as_deref() == Some("Hyper") + { + progress_bar.set_empty_bar_character("░"); // light shade character ░ + progress_bar.set_progress_character(""); + progress_bar.set_bar_character("▓"); // dark shade character ▓ + } + + progress_bar } - pub fn ask( + fn get_progress_bar(&mut self) -> &mut ProgressBar { + if self.progress_bar.is_none() { + // TODO(plugin): recoverable error path. + let _ = RuntimeException(shirabe_php_shim::RuntimeException { + message: "The ProgressBar is not started.".to_string(), + code: 0, + }); + todo!() + } + + self.progress_bar.as_mut().unwrap() + } + + fn auto_prepend_block(&mut self) { + let chars = shirabe_php_shim::substr( + &shirabe_php_shim::str_replace( + shirabe_php_shim::PHP_EOL, + "\n", + &self.buffered_output.fetch(), + ), + -2, + None, + ); + + if chars.is_empty() { + self.new_line(1); // empty history, so we should start with a new line. + + return; + } + // Prepend new line for each non LF chars (This means no blank line was output before) + self.new_line(2 - shirabe_php_shim::substr_count(&chars, "\n")); + } + + fn auto_prepend_text(&mut self) { + let fetched = self.buffered_output.fetch(); + // Prepend new line if last char isn't EOL: + if !shirabe_php_shim::str_ends_with(&fetched, "\n") { + self.new_line(1); + } + } + + fn write_buffer(&mut self, message: &str, new_line: bool, r#type: i64) { + // We need to know if the last chars are PHP_EOL + self.buffered_output + .write(&[message.to_string()], new_line, r#type); + } + + fn create_block( &mut self, - _question: &str, - _default: Option<&str>, - _validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, - ) -> PhpMixed { + messages: Vec<PhpMixed>, + r#type: Option<&str>, + style: Option<&str>, + prefix: &str, + padding: bool, + escape: bool, + ) -> Vec<String> { + let mut indent_length: i64 = 0; + let prefix_length = Helper::width(&Helper::remove_decoration( + &mut *self.get_formatter().borrow_mut(), + prefix, + )); + let mut lines: Vec<String> = Vec::new(); + + let mut r#type = r#type.map(|t| t.to_string()); + let mut line_indentation = String::new(); + if let Some(t) = &r#type { + let formatted = shirabe_php_shim::sprintf("[%s] ", &[PhpMixed::String(t.clone())]); + indent_length = shirabe_php_shim::strlen(&formatted); + line_indentation = shirabe_php_shim::str_repeat(" ", indent_length as usize); + r#type = Some(formatted); + } + + let messages_count = messages.len() as i64; + // wrap and add newlines for each element + for (key, message) in messages.into_iter().enumerate() { + let key = key as i64; + let mut message = Self::php_string(&message); + if escape { + message = OutputFormatter::escape(&message).unwrap(); + } + + let decoration_length = Helper::width(&message) + - Helper::width(&Helper::remove_decoration( + &mut *self.get_formatter().borrow_mut(), + &message, + )); + let message_line_length = shirabe_php_shim::min( + self.line_length - prefix_length - indent_length + decoration_length, + self.line_length, + ); + let message_lines = shirabe_php_shim::explode( + shirabe_php_shim::PHP_EOL, + &shirabe_php_shim::wordwrap( + &message, + message_line_length, + shirabe_php_shim::PHP_EOL, + true, + ), + ); + for message_line in message_lines { + lines.push(message_line); + } + + if messages_count > 1 && key < messages_count - 1 { + lines.push(String::new()); + } + } + + let mut first_line_index: i64 = 0; + if padding && self.inner.is_decorated() { + first_line_index = 1; + shirabe_php_shim::array_unshift(&mut lines, String::new()); + lines.push(String::new()); + } + + for (i, line) in lines.iter_mut().enumerate() { + let i = i as i64; + if let Some(t) = &r#type { + *line = if first_line_index == i { + format!("{}{}", t, line) + } else { + format!("{}{}", line_indentation, line) + }; + } + + *line = format!("{}{}", prefix, line); + line.push_str(&shirabe_php_shim::str_repeat( + " ", + shirabe_php_shim::max( + self.line_length + - Helper::width(&Helper::remove_decoration( + &mut *self.output.borrow().get_formatter().borrow_mut(), + line, + )), + 0, + ) as usize, + )); + + if let Some(style) = style { + *line = shirabe_php_shim::sprintf( + "<%s>%s</>", + &[ + PhpMixed::String(style.to_string()), + PhpMixed::String(line.clone()), + ], + ); + } + } + + lines + } + + fn get_formatter(&self) -> Rc<RefCell<dyn OutputFormatterInterface>> { + self.output.borrow().get_formatter() + } + + fn is_console_output_interface(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { todo!() } - pub fn ask_hidden( - &mut self, - _question: &str, - _validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, - ) -> PhpMixed { + fn as_console_output_interface( + _output: &Rc<RefCell<dyn OutputInterface>>, + ) -> Option<Rc<RefCell<dyn ConsoleOutputInterface>>> { todo!() } - pub fn confirm(&mut self, _question: &str, _default: bool) -> bool { + fn is_table_separator(_value: &PhpMixed) -> bool { todo!() } - pub fn choice( + fn php_string(_value: &PhpMixed) -> String { + todo!() + } + + /// Bridges the `StyleInterface` validator (which yields `anyhow::Error`) to the + /// `Question::set_validator` validator (which yields `InvalidArgumentException`) by + /// converting any error into an `InvalidArgumentException` carrying its message. + #[allow(clippy::type_complexity)] + fn adapt_validator( + validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, + ) -> Option<Box<dyn Fn(Option<PhpMixed>) -> Result<PhpMixed, InvalidArgumentException>>> { + validator.map(|validator| { + Box::new(move |value: Option<PhpMixed>| { + validator(value).map_err(|e| { + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: e.to_string(), + code: 0, + }) + }) + }) + as Box<dyn Fn(Option<PhpMixed>) -> Result<PhpMixed, InvalidArgumentException>> + }) + } +} + +impl SymfonyStyle { + /// {@inheritdoc} + pub fn writeln(&mut self, messages: PhpMixed, r#type: i64) { + let messages: Vec<PhpMixed> = if !shirabe_php_shim::is_iterable(&messages) { + vec![messages] + } else { + // iterate over the iterable + todo!() + }; + + for message in messages { + let message = Self::php_string(&message); + self.inner.writeln(&[message.clone()], r#type); + self.write_buffer(&message, true, r#type); + } + } + + /// {@inheritdoc} + pub fn write(&mut self, messages: PhpMixed, newline: bool, r#type: i64) { + let messages: Vec<PhpMixed> = if !shirabe_php_shim::is_iterable(&messages) { + vec![messages] + } else { + // iterate over the iterable + todo!() + }; + + for message in messages { + let message = Self::php_string(&message); + self.inner.write(&[message.clone()], newline, r#type); + self.write_buffer(&message, newline, r#type); + } + } +} + +impl StyleInterface for SymfonyStyle { + /// {@inheritdoc} + fn title(&mut self, message: &str) { + self.auto_prepend_block(); + self.writeln( + PhpMixed::List(vec![ + Box::new(PhpMixed::String(shirabe_php_shim::sprintf( + "<comment>%s</>", + &[PhpMixed::String( + OutputFormatter::escape_trailing_backslash(message), + )], + ))), + Box::new(PhpMixed::String(shirabe_php_shim::sprintf( + "<comment>%s</>", + &[PhpMixed::String(shirabe_php_shim::str_repeat( + "=", + Helper::width(&Helper::remove_decoration( + &mut *self.get_formatter().borrow_mut(), + message, + )) as usize, + ))], + ))), + ]), + OUTPUT_NORMAL, + ); + self.new_line(1); + } + + /// {@inheritdoc} + fn section(&mut self, message: &str) { + self.auto_prepend_block(); + self.writeln( + PhpMixed::List(vec![ + Box::new(PhpMixed::String(shirabe_php_shim::sprintf( + "<comment>%s</>", + &[PhpMixed::String( + OutputFormatter::escape_trailing_backslash(message), + )], + ))), + Box::new(PhpMixed::String(shirabe_php_shim::sprintf( + "<comment>%s</>", + &[PhpMixed::String(shirabe_php_shim::str_repeat( + "-", + Helper::width(&Helper::remove_decoration( + &mut *self.get_formatter().borrow_mut(), + message, + )) as usize, + ))], + ))), + ]), + OUTPUT_NORMAL, + ); + self.new_line(1); + } + + /// {@inheritdoc} + fn listing(&mut self, elements: Vec<PhpMixed>) { + self.auto_prepend_text(); + let elements: Vec<PhpMixed> = shirabe_php_shim::array_map( + |element: &PhpMixed| { + PhpMixed::String(shirabe_php_shim::sprintf(" * %s", &[element.clone()])) + }, + &elements, + ); + + self.writeln( + PhpMixed::List(elements.into_iter().map(Box::new).collect()), + OUTPUT_NORMAL, + ); + self.new_line(1); + } + + /// {@inheritdoc} + fn text(&mut self, message: PhpMixed) { + self.auto_prepend_text(); + + let messages: Vec<PhpMixed> = if shirabe_php_shim::is_array(&message) { + // array_values($message) + todo!() + } else { + vec![message] + }; + for message in messages { + self.writeln( + PhpMixed::String(shirabe_php_shim::sprintf(" %s", &[message])), + OUTPUT_NORMAL, + ); + } + } + + /// {@inheritdoc} + fn success(&mut self, message: PhpMixed) { + self.block( + message, + Some("OK"), + Some("fg=black;bg=green"), + " ", + true, + true, + ); + } + + /// {@inheritdoc} + fn error(&mut self, message: PhpMixed) { + self.block( + message, + Some("ERROR"), + Some("fg=white;bg=red"), + " ", + true, + true, + ); + } + + /// {@inheritdoc} + fn warning(&mut self, message: PhpMixed) { + self.block( + message, + Some("WARNING"), + Some("fg=black;bg=yellow"), + " ", + true, + true, + ); + } + + /// {@inheritdoc} + fn note(&mut self, message: PhpMixed) { + self.block(message, Some("NOTE"), Some("fg=yellow"), " ! ", false, true); + } + + /// {@inheritdoc} + fn caution(&mut self, message: PhpMixed) { + self.block( + message, + Some("CAUTION"), + Some("fg=white;bg=red"), + " ! ", + true, + true, + ); + } + + /// {@inheritdoc} + fn table(&mut self, headers: Vec<PhpMixed>, rows: Vec<PhpMixed>) { + self.create_table() + .set_headers(headers) + .set_rows(rows) + .render(); + + self.new_line(1); + } + + /// {@inheritdoc} + fn ask( &mut self, - _question: &str, - _choices: Vec<PhpMixed>, - _default: Option<PhpMixed>, + question: &str, + default: Option<&str>, + validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, ) -> PhpMixed { - todo!() + let mut question = Question::new( + question.to_string(), + default.map(|d| PhpMixed::String(d.to_string())), + ); + question.set_validator(Self::adapt_validator(validator)); + + self.ask_question(question) } - pub fn table(&mut self, _headers: Vec<PhpMixed>, _rows: Vec<PhpMixed>) { - todo!() + /// {@inheritdoc} + fn ask_hidden( + &mut self, + question: &str, + validator: Option<Box<dyn Fn(Option<PhpMixed>) -> anyhow::Result<PhpMixed>>>, + ) -> PhpMixed { + let mut question = Question::new(question.to_string(), None); + + question.set_hidden(true); + question.set_validator(Self::adapt_validator(validator)); + + self.ask_question(question) } - pub fn progress_start(&mut self, _max: i64) { - todo!() + /// {@inheritdoc} + fn confirm(&mut self, question: &str, default: bool) -> bool { + // PHP: return $this->askQuestion(new ConfirmationQuestion($question, $default)); + // ConfirmationQuestion extends Question, but the ported type embeds a private + // `inner: Question` and cannot be passed to `ask_question(Question)`. The + // Question class hierarchy needs a trait/enum to dispatch polymorphically. + let _confirmation_question = + ConfirmationQuestion::new(question.to_string(), default, "/^y/i".to_string()); + let answer = self.ask_question(todo!()); + // PHP returns the bool answer from ConfirmationQuestion. + match answer { + PhpMixed::Bool(b) => b, + _ => todo!(), + } } - pub fn progress_advance(&mut self, _step: i64) { - todo!() + /// {@inheritdoc} + fn choice( + &mut self, + question: &str, + choices: Vec<PhpMixed>, + default: Option<PhpMixed>, + ) -> PhpMixed { + let default = if default.is_some() { + let default = default.unwrap(); + let values = shirabe_php_shim::array_flip(&PhpMixed::List( + choices.iter().cloned().map(Box::new).collect(), + )); + // $default = $values[$default] ?? $default; + let _ = values; + Some(default) + } else { + default + }; + + // PHP: return $this->askQuestion(new ChoiceQuestion($question, $choices, $default)); + // ChoiceQuestion extends Question; see `confirm` for the polymorphism note. + let choices_map: indexmap::IndexMap<String, Box<PhpMixed>> = choices + .into_iter() + .enumerate() + .map(|(i, c)| (i.to_string(), Box::new(c))) + .collect(); + let _choice_question = ChoiceQuestion::new(question.to_string(), choices_map, default); + self.ask_question(todo!()) } - pub fn progress_finish(&mut self) { - todo!() + /// {@inheritdoc} + fn new_line(&mut self, count: i64) { + self.inner.new_line(count); + self.buffered_output.write( + &[shirabe_php_shim::str_repeat("\n", count as usize)], + false, + OUTPUT_NORMAL, + ); } - pub fn is_debug(&self) -> bool { - todo!() + /// {@inheritdoc} + fn progress_start(&mut self, max: i64) { + let mut progress_bar = self.create_progress_bar(max); + progress_bar.start(None); + self.progress_bar = Some(progress_bar); } - pub fn writeln(&mut self, _messages: PhpMixed, _type: i64) { - todo!() + /// {@inheritdoc} + fn progress_advance(&mut self, step: i64) { + self.get_progress_bar().advance(step); } - pub fn write(&mut self, _messages: PhpMixed, _newline: bool, _type: i64) { - todo!() + /// {@inheritdoc} + fn progress_finish(&mut self) { + self.get_progress_bar().finish(); + self.new_line(2); + self.progress_bar = None; } } diff --git a/crates/shirabe-external-packages/src/symfony/console/terminal.rs b/crates/shirabe-external-packages/src/symfony/console/terminal.rs index 251d71e..8163b28 100644 --- a/crates/shirabe-external-packages/src/symfony/console/terminal.rs +++ b/crates/shirabe-external-packages/src/symfony/console/terminal.rs @@ -1,3 +1,12 @@ +use shirabe_php_shim::PhpMixed; +use std::cell::Cell; + +thread_local! { + static WIDTH: Cell<Option<i64>> = const { Cell::new(None) }; + static HEIGHT: Cell<Option<i64>> = const { Cell::new(None) }; + static STTY: Cell<Option<bool>> = const { Cell::new(None) }; +} + #[derive(Debug)] pub struct Terminal; @@ -9,14 +18,249 @@ impl Default for Terminal { impl Terminal { pub fn new() -> Self { - todo!() + Terminal } + /// Gets the terminal width. pub fn get_width(&self) -> i64 { - todo!() + let width = shirabe_php_shim::getenv("COLUMNS"); + if let Some(width) = width { + return shirabe_php_shim::intval(&PhpMixed::String(shirabe_php_shim::trim( + &width, None, + ))); + } + + if WIDTH.with(|w| w.get()).is_none() { + Self::init_dimensions(); + } + + WIDTH.with(|w| w.get()).filter(|&v| v != 0).unwrap_or(80) } + /// Gets the terminal height. pub fn get_height(&self) -> i64 { - todo!() + let height = shirabe_php_shim::getenv("LINES"); + if let Some(height) = height { + return shirabe_php_shim::intval(&PhpMixed::String(shirabe_php_shim::trim( + &height, None, + ))); + } + + if HEIGHT.with(|h| h.get()).is_none() { + Self::init_dimensions(); + } + + HEIGHT.with(|h| h.get()).filter(|&v| v != 0).unwrap_or(50) + } + + pub fn has_stty_available() -> bool { + if let Some(stty) = STTY.with(|s| s.get()) { + return stty; + } + + // skip check if shell_exec function is disabled + if !shirabe_php_shim::function_exists("shell_exec") { + return false; + } + + let result = shirabe_php_shim::shell_exec(&format!( + "stty 2> {}", + if shirabe_php_shim::DIRECTORY_SEPARATOR == "\\" { + "NUL" + } else { + "/dev/null" + } + )) + .is_some(); + STTY.with(|s| s.set(Some(result))); + result + } + + fn init_dimensions() { + if shirabe_php_shim::DIRECTORY_SEPARATOR == "\\" { + let ansicon = shirabe_php_shim::getenv("ANSICON"); + let mut matches: Vec<Option<String>> = Vec::new(); + if let Some(ansicon) = &ansicon { + if shirabe_php_shim::preg_match( + "/^(\\d+)x(\\d+)(?: \\((\\d+)x(\\d+)\\))?$/", + &shirabe_php_shim::trim(ansicon, None), + &mut matches, + ) != 0 + { + // extract [w, H] from "wxh (WxH)" + // or [w, h] from "wxh" + WIDTH.with(|w| { + w.set(Some(shirabe_php_shim::intval(&PhpMixed::String( + matches[1].clone().unwrap_or_default(), + )))) + }); + HEIGHT.with(|h| { + let value = if matches.get(4).map(|m| m.is_some()).unwrap_or(false) { + shirabe_php_shim::intval(&PhpMixed::String( + matches[4].clone().unwrap_or_default(), + )) + } else { + shirabe_php_shim::intval(&PhpMixed::String( + matches[2].clone().unwrap_or_default(), + )) + }; + h.set(Some(value)); + }); + return; + } + } + + if !Self::has_vt100_support() && Self::has_stty_available() { + // only use stty on Windows if the terminal does not support vt100 (e.g. Windows 7 + git-bash) + // testing for stty in a Windows 10 vt100-enabled console will implicitly disable vt100 support on STDOUT + Self::init_dimensions_using_stty(); + } else if let Some(dimensions) = Self::get_console_mode() { + // extract [w, h] from "wxh" + WIDTH.with(|w| w.set(Some(dimensions[0]))); + HEIGHT.with(|h| h.set(Some(dimensions[1]))); + } + } else { + Self::init_dimensions_using_stty(); + } + } + + /// Returns whether STDOUT has vt100 support (some Windows 10+ configurations). + fn has_vt100_support() -> bool { + shirabe_php_shim::function_exists("sapi_windows_vt100_support") && { + // PHP passes the `fopen('php://stdout', 'w')` resource. The shim's `fopen` + // returns `PhpMixed` but `sapi_windows_vt100_support` expects + // `&PhpResource`; bridge with a placeholder resource. + let _ = shirabe_php_shim::fopen("php://stdout", "w"); + shirabe_php_shim::sapi_windows_vt100_support(&shirabe_php_shim::PhpResource) + } + } + + /// Initializes dimensions using the output of an stty columns line. + fn init_dimensions_using_stty() { + if let Some(stty_string) = Self::get_stty_columns() { + if stty_string.is_empty() { + return; + } + let mut matches: Vec<Option<String>> = Vec::new(); + if shirabe_php_shim::preg_match( + "/rows.(\\d+);.columns.(\\d+);/i", + &stty_string, + &mut matches, + ) != 0 + { + // extract [w, h] from "rows h; columns w;" + WIDTH.with(|w| { + w.set(Some(shirabe_php_shim::intval(&PhpMixed::String( + matches[2].clone().unwrap_or_default(), + )))) + }); + HEIGHT.with(|h| { + h.set(Some(shirabe_php_shim::intval(&PhpMixed::String( + matches[1].clone().unwrap_or_default(), + )))) + }); + } else if shirabe_php_shim::preg_match( + "/;.(\\d+).rows;.(\\d+).columns/i", + &stty_string, + &mut matches, + ) != 0 + { + // extract [w, h] from "; h rows; w columns" + WIDTH.with(|w| { + w.set(Some(shirabe_php_shim::intval(&PhpMixed::String( + matches[2].clone().unwrap_or_default(), + )))) + }); + HEIGHT.with(|h| { + h.set(Some(shirabe_php_shim::intval(&PhpMixed::String( + matches[1].clone().unwrap_or_default(), + )))) + }); + } + } + } + + /// Runs and parses mode CON if it's available, suppressing any error output. + /// + /// Returns an array composed of the width and the height or null if it could not be parsed. + fn get_console_mode() -> Option<Vec<i64>> { + let info = Self::read_from_process("mode CON"); + + let info = info?; + let mut matches: Vec<Option<String>> = Vec::new(); + if shirabe_php_shim::preg_match( + "/--------+\\r?\\n.+?(\\d+)\\r?\\n.+?(\\d+)\\r?\\n/", + &info, + &mut matches, + ) == 0 + { + return None; + } + + Some(vec![ + shirabe_php_shim::intval(&PhpMixed::String(matches[2].clone().unwrap_or_default())), + shirabe_php_shim::intval(&PhpMixed::String(matches[1].clone().unwrap_or_default())), + ]) + } + + /// Runs and parses stty -a if it's available, suppressing any error output. + fn get_stty_columns() -> Option<String> { + Self::read_from_process("stty -a | grep columns") + } + + fn read_from_process(command: &str) -> Option<String> { + if !shirabe_php_shim::function_exists("proc_open") { + return None; + } + + let descriptorspec: Vec<PhpMixed> = vec![ + PhpMixed::List(vec![ + Box::new(PhpMixed::String("pipe".to_string())), + Box::new(PhpMixed::String("w".to_string())), + ]), + PhpMixed::List(vec![ + Box::new(PhpMixed::String("pipe".to_string())), + Box::new(PhpMixed::String("w".to_string())), + ]), + ]; + + let cp = if shirabe_php_shim::function_exists("sapi_windows_cp_set") { + shirabe_php_shim::sapi_windows_cp_get(None) + } else { + 0 + }; + + let mut pipes = PhpMixed::Null; + let process = shirabe_php_shim::proc_open(command, &descriptorspec, &mut pipes); + if !process { + return None; + } + + // $pipes[1] and $pipes[2] from proc_open's output array. + let info = shirabe_php_shim::stream_get_contents(php_pipe(&pipes, 1)); + shirabe_php_shim::fclose(php_pipe(&pipes, 1)); + shirabe_php_shim::fclose(php_pipe(&pipes, 2)); + shirabe_php_shim::proc_close(process); + + if cp != 0 { + shirabe_php_shim::sapi_windows_cp_set(cp); + } + + info + } +} + +/// Indexes into the `$pipes` array populated by proc_open. +fn php_pipe(pipes: &PhpMixed, index: i64) -> PhpMixed { + match pipes { + PhpMixed::List(list) => list + .get(index as usize) + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Null), + PhpMixed::Array(array) => array + .get(&index.to_string()) + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Null), + _ => PhpMixed::Null, } } |
