From 6051927c8fa32cfffa102d2a170c5a6cf747a1b9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 9 Aug 2026 11:19:03 +0900 Subject: refactor(symfony-console): extract symfony/console into the shirabe-symfony-console crate Move `Symfony\Component\Console` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_console::application::Application` instead of `shirabe_external_packages::symfony::console::application::Application`. The `delegate_to_inner!` and `delegate_command_trait_impls_to_inner!` macros move with it. Co-Authored-By: Claude Opus 5 (1M context) --- crates/shirabe-symfony-console/Cargo.toml | 16 + .../src/Resources/completion.bash | 84 ++ crates/shirabe-symfony-console/src/application.rs | 42 + crates/shirabe-symfony-console/src/attribute.rs | 3 + .../src/attribute/as_command.rs | 36 + crates/shirabe-symfony-console/src/color.rs | 222 +++ crates/shirabe-symfony-console/src/command.rs | 13 + .../shirabe-symfony-console/src/command/command.rs | 897 ++++++++++++ .../src/command/complete_command.rs | 415 ++++++ .../src/command/dump_completion_command.rs | 295 ++++ .../src/command/help_command.rs | 171 +++ .../src/command/list_command.rs | 172 +++ .../src/command/signalable_command_interface.rs | 10 + .../shirabe-symfony-console/src/command_loader.rs | 3 + .../src/command_loader/command_loader_interface.rs | 15 + crates/shirabe-symfony-console/src/completion.rs | 9 + .../src/completion/completion_input.rs | 404 ++++++ .../src/completion/completion_suggestions.rs | 76 ++ .../src/completion/output.rs | 5 + .../completion/output/bash_completion_output.rs | 25 + .../output/completion_output_interface.rs | 9 + .../src/completion/suggestion.rs | 23 + crates/shirabe-symfony-console/src/cursor.rs | 93 ++ crates/shirabe-symfony-console/src/descriptor.rs | 15 + .../src/descriptor/application_description.rs | 189 +++ .../src/descriptor/descriptor.rs | 97 ++ .../src/descriptor/descriptor_interface.rs | 29 + .../src/descriptor/json_descriptor.rs | 402 ++++++ .../src/descriptor/markdown_descriptor.rs | 378 +++++ .../src/descriptor/text_descriptor.rs | 592 ++++++++ .../src/descriptor/xml_descriptor.rs | 403 ++++++ crates/shirabe-symfony-console/src/exception.rs | 17 + .../src/exception/command_not_found_exception.rs | 30 + .../src/exception/exception_interface.rs | 3 + .../src/exception/invalid_argument_exception.rs | 20 + .../src/exception/invalid_option_exception.rs | 21 + .../src/exception/logic_exception.rs | 20 + .../src/exception/missing_input_exception.rs | 21 + .../src/exception/namespace_not_found_exception.rs | 21 + .../src/exception/runtime_exception.rs | 20 + crates/shirabe-symfony-console/src/formatter.rs | 13 + .../src/formatter/output_formatter.rs | 356 +++++ .../src/formatter/output_formatter_interface.rs | 29 + .../src/formatter/output_formatter_style.rs | 106 ++ .../formatter/output_formatter_style_interface.rs | 27 + .../src/formatter/output_formatter_style_stack.rs | 100 ++ .../wrappable_output_formatter_interface.rs | 13 + crates/shirabe-symfony-console/src/helper.rs | 33 + .../src/helper/debug_formatter_helper.rs | 175 +++ .../src/helper/descriptor_helper.rs | 119 ++ .../src/helper/formatter_helper.rs | 99 ++ .../shirabe-symfony-console/src/helper/helper.rs | 162 +++ .../src/helper/helper_interface.rs | 15 + .../src/helper/helper_set.rs | 76 ++ .../src/helper/process_helper.rs | 310 +++++ .../src/helper/progress_bar.rs | 835 +++++++++++ .../src/helper/question_helper.rs | 916 +++++++++++++ .../src/helper/symfony_question_helper.rs | 163 +++ crates/shirabe-symfony-console/src/helper/table.rs | 1443 ++++++++++++++++++++ .../src/helper/table_cell.rs | 99 ++ .../src/helper/table_cell_style.rs | 114 ++ .../src/helper/table_rows.rs | 45 + .../src/helper/table_separator.rs | 46 + .../src/helper/table_style.rs | 289 ++++ crates/shirabe-symfony-console/src/input.rs | 21 + .../src/input/argv_input.rs | 657 +++++++++ .../src/input/array_input.rs | 386 ++++++ crates/shirabe-symfony-console/src/input/input.rs | 225 +++ .../src/input/input_argument.rs | 96 ++ .../src/input/input_aware_interface.rs | 7 + .../src/input/input_definition.rs | 450 ++++++ .../src/input/input_interface.rs | 60 + .../src/input/input_option.rs | 193 +++ .../src/input/streamable_input_interface.rs | 10 + .../src/input/string_input.rs | 243 ++++ crates/shirabe-symfony-console/src/lib.rs | 36 + crates/shirabe-symfony-console/src/output.rs | 17 + .../src/output/buffered_output.rs | 84 ++ .../src/output/console_output.rs | 210 +++ .../src/output/console_output_interface.rs | 15 + .../src/output/console_section_output.rs | 203 +++ .../shirabe-symfony-console/src/output/output.rs | 159 +++ .../src/output/output_interface.rs | 64 + .../src/output/stream_output.rs | 200 +++ .../src/output/trimmed_buffer_output.rs | 99 ++ crates/shirabe-symfony-console/src/question.rs | 7 + .../src/question/choice_question.rs | 286 ++++ .../src/question/confirmation_question.rs | 113 ++ .../src/question/question.rs | 371 +++++ .../shirabe-symfony-console/src/signal_registry.rs | 3 + .../src/signal_registry/signal_registry.rs | 102 ++ crates/shirabe-symfony-console/src/style.rs | 7 + .../src/style/output_style.rs | 122 ++ .../src/style/style_interface.rs | 74 + .../src/style/symfony_style.rs | 753 ++++++++++ crates/shirabe-symfony-console/src/terminal.rs | 253 ++++ crates/shirabe-symfony-console/src/tester.rs | 3 + .../src/tester/command_completion_tester.rs | 54 + 98 files changed, 16482 insertions(+) create mode 100644 crates/shirabe-symfony-console/Cargo.toml create mode 100644 crates/shirabe-symfony-console/src/Resources/completion.bash create mode 100644 crates/shirabe-symfony-console/src/application.rs create mode 100644 crates/shirabe-symfony-console/src/attribute.rs create mode 100644 crates/shirabe-symfony-console/src/attribute/as_command.rs create mode 100644 crates/shirabe-symfony-console/src/color.rs create mode 100644 crates/shirabe-symfony-console/src/command.rs create mode 100644 crates/shirabe-symfony-console/src/command/command.rs create mode 100644 crates/shirabe-symfony-console/src/command/complete_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/dump_completion_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/help_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/list_command.rs create mode 100644 crates/shirabe-symfony-console/src/command/signalable_command_interface.rs create mode 100644 crates/shirabe-symfony-console/src/command_loader.rs create mode 100644 crates/shirabe-symfony-console/src/command_loader/command_loader_interface.rs create mode 100644 crates/shirabe-symfony-console/src/completion.rs create mode 100644 crates/shirabe-symfony-console/src/completion/completion_input.rs create mode 100644 crates/shirabe-symfony-console/src/completion/completion_suggestions.rs create mode 100644 crates/shirabe-symfony-console/src/completion/output.rs create mode 100644 crates/shirabe-symfony-console/src/completion/output/bash_completion_output.rs create mode 100644 crates/shirabe-symfony-console/src/completion/output/completion_output_interface.rs create mode 100644 crates/shirabe-symfony-console/src/completion/suggestion.rs create mode 100644 crates/shirabe-symfony-console/src/cursor.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor/application_description.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor/descriptor.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor/descriptor_interface.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor/json_descriptor.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor/markdown_descriptor.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor/text_descriptor.rs create mode 100644 crates/shirabe-symfony-console/src/descriptor/xml_descriptor.rs create mode 100644 crates/shirabe-symfony-console/src/exception.rs create mode 100644 crates/shirabe-symfony-console/src/exception/command_not_found_exception.rs create mode 100644 crates/shirabe-symfony-console/src/exception/exception_interface.rs create mode 100644 crates/shirabe-symfony-console/src/exception/invalid_argument_exception.rs create mode 100644 crates/shirabe-symfony-console/src/exception/invalid_option_exception.rs create mode 100644 crates/shirabe-symfony-console/src/exception/logic_exception.rs create mode 100644 crates/shirabe-symfony-console/src/exception/missing_input_exception.rs create mode 100644 crates/shirabe-symfony-console/src/exception/namespace_not_found_exception.rs create mode 100644 crates/shirabe-symfony-console/src/exception/runtime_exception.rs create mode 100644 crates/shirabe-symfony-console/src/formatter.rs create mode 100644 crates/shirabe-symfony-console/src/formatter/output_formatter.rs create mode 100644 crates/shirabe-symfony-console/src/formatter/output_formatter_interface.rs create mode 100644 crates/shirabe-symfony-console/src/formatter/output_formatter_style.rs create mode 100644 crates/shirabe-symfony-console/src/formatter/output_formatter_style_interface.rs create mode 100644 crates/shirabe-symfony-console/src/formatter/output_formatter_style_stack.rs create mode 100644 crates/shirabe-symfony-console/src/formatter/wrappable_output_formatter_interface.rs create mode 100644 crates/shirabe-symfony-console/src/helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/debug_formatter_helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/descriptor_helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/formatter_helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/helper_interface.rs create mode 100644 crates/shirabe-symfony-console/src/helper/helper_set.rs create mode 100644 crates/shirabe-symfony-console/src/helper/process_helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/progress_bar.rs create mode 100644 crates/shirabe-symfony-console/src/helper/question_helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs create mode 100644 crates/shirabe-symfony-console/src/helper/table.rs create mode 100644 crates/shirabe-symfony-console/src/helper/table_cell.rs create mode 100644 crates/shirabe-symfony-console/src/helper/table_cell_style.rs create mode 100644 crates/shirabe-symfony-console/src/helper/table_rows.rs create mode 100644 crates/shirabe-symfony-console/src/helper/table_separator.rs create mode 100644 crates/shirabe-symfony-console/src/helper/table_style.rs create mode 100644 crates/shirabe-symfony-console/src/input.rs create mode 100644 crates/shirabe-symfony-console/src/input/argv_input.rs create mode 100644 crates/shirabe-symfony-console/src/input/array_input.rs create mode 100644 crates/shirabe-symfony-console/src/input/input.rs create mode 100644 crates/shirabe-symfony-console/src/input/input_argument.rs create mode 100644 crates/shirabe-symfony-console/src/input/input_aware_interface.rs create mode 100644 crates/shirabe-symfony-console/src/input/input_definition.rs create mode 100644 crates/shirabe-symfony-console/src/input/input_interface.rs create mode 100644 crates/shirabe-symfony-console/src/input/input_option.rs create mode 100644 crates/shirabe-symfony-console/src/input/streamable_input_interface.rs create mode 100644 crates/shirabe-symfony-console/src/input/string_input.rs create mode 100644 crates/shirabe-symfony-console/src/lib.rs create mode 100644 crates/shirabe-symfony-console/src/output.rs create mode 100644 crates/shirabe-symfony-console/src/output/buffered_output.rs create mode 100644 crates/shirabe-symfony-console/src/output/console_output.rs create mode 100644 crates/shirabe-symfony-console/src/output/console_output_interface.rs create mode 100644 crates/shirabe-symfony-console/src/output/console_section_output.rs create mode 100644 crates/shirabe-symfony-console/src/output/output.rs create mode 100644 crates/shirabe-symfony-console/src/output/output_interface.rs create mode 100644 crates/shirabe-symfony-console/src/output/stream_output.rs create mode 100644 crates/shirabe-symfony-console/src/output/trimmed_buffer_output.rs create mode 100644 crates/shirabe-symfony-console/src/question.rs create mode 100644 crates/shirabe-symfony-console/src/question/choice_question.rs create mode 100644 crates/shirabe-symfony-console/src/question/confirmation_question.rs create mode 100644 crates/shirabe-symfony-console/src/question/question.rs create mode 100644 crates/shirabe-symfony-console/src/signal_registry.rs create mode 100644 crates/shirabe-symfony-console/src/signal_registry/signal_registry.rs create mode 100644 crates/shirabe-symfony-console/src/style.rs create mode 100644 crates/shirabe-symfony-console/src/style/output_style.rs create mode 100644 crates/shirabe-symfony-console/src/style/style_interface.rs create mode 100644 crates/shirabe-symfony-console/src/style/symfony_style.rs create mode 100644 crates/shirabe-symfony-console/src/terminal.rs create mode 100644 crates/shirabe-symfony-console/src/tester.rs create mode 100644 crates/shirabe-symfony-console/src/tester/command_completion_tester.rs (limited to 'crates/shirabe-symfony-console') diff --git a/crates/shirabe-symfony-console/Cargo.toml b/crates/shirabe-symfony-console/Cargo.toml new file mode 100644 index 00000000..93b18903 --- /dev/null +++ b/crates/shirabe-symfony-console/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "shirabe-symfony-console" +version.workspace = true +edition.workspace = true + +[dependencies] +shirabe-pcre.workspace = true +shirabe-php-shim.workspace = true +shirabe-symfony-process.workspace = true +shirabe-symfony-string.workspace = true +anyhow.workspace = true +indexmap.workspace = true +regex.workspace = true + +[lints] +workspace = true diff --git a/crates/shirabe-symfony-console/src/Resources/completion.bash b/crates/shirabe-symfony-console/src/Resources/completion.bash new file mode 100644 index 00000000..bb44037b --- /dev/null +++ b/crates/shirabe-symfony-console/src/Resources/completion.bash @@ -0,0 +1,84 @@ +# This file is part of the Symfony package. +# +# (c) Fabien Potencier +# +# For the full copyright and license information, please view +# https://symfony.com/doc/current/contributing/code/license.html + +_sf_{{ COMMAND_NAME }}() { + # Use newline as only separator to allow space in completion values + local IFS=$'\n' + local sf_cmd="${COMP_WORDS[0]}" + + # for an alias, get the real script behind it + sf_cmd_type=$(type -t $sf_cmd) + if [[ $sf_cmd_type == "alias" ]]; then + sf_cmd=$(alias $sf_cmd | sed -E "s/alias $sf_cmd='(.*)'/\1/") + elif [[ $sf_cmd_type == "file" ]]; then + sf_cmd=$(type -p $sf_cmd) + fi + + if [[ $sf_cmd_type != "function" && ! -x $sf_cmd ]]; then + return 1 + fi + + local cur prev words cword + _get_comp_words_by_ref -n := cur prev words cword + + local completecmd=("$sf_cmd" "_complete" "--no-interaction" "-sbash" "-c$cword" "-S{{ VERSION }}") + for w in ${words[@]}; do + w=$(printf -- '%b' "$w") + # remove quotes from typed values + quote="${w:0:1}" + if [ "$quote" == \' ]; then + w="${w%\'}" + w="${w#\'}" + elif [ "$quote" == \" ]; then + w="${w%\"}" + w="${w#\"}" + fi + # empty values are ignored + if [ ! -z "$w" ]; then + completecmd+=("-i$w") + fi + done + + local sfcomplete + if sfcomplete=$(${completecmd[@]} 2>&1); then + local quote suggestions + quote=${cur:0:1} + + # Use single quotes by default if suggestions contains backslash (FQCN) + if [ "$quote" == '' ] && [[ "$sfcomplete" =~ \\ ]]; then + quote=\' + fi + + if [ "$quote" == \' ]; then + # single quotes: no additional escaping (does not accept ' in values) + suggestions=$(for s in $sfcomplete; do printf $'%q%q%q\n' "$quote" "$s" "$quote"; done) + elif [ "$quote" == \" ]; then + # double quotes: double escaping for \ $ ` " + suggestions=$(for s in $sfcomplete; do + s=${s//\\/\\\\} + s=${s//\$/\\\$} + s=${s//\`/\\\`} + s=${s//\"/\\\"} + printf $'%q%q%q\n' "$quote" "$s" "$quote"; + done) + else + # no quotes: double escaping + suggestions=$(for s in $sfcomplete; do printf $'%q\n' $(printf '%q' "$s"); done) + fi + COMPREPLY=($(IFS=$'\n' compgen -W "$suggestions" -- $(printf -- "%q" "$cur"))) + __ltrim_colon_completions "$cur" + else + if [[ "$sfcomplete" != *"Command \"_complete\" is not defined."* ]]; then + >&2 echo + >&2 echo $sfcomplete + fi + + return 1 + fi +} + +complete -F _sf_{{ COMMAND_NAME }} {{ COMMAND_NAME }} diff --git a/crates/shirabe-symfony-console/src/application.rs b/crates/shirabe-symfony-console/src/application.rs new file mode 100644 index 00000000..192649ef --- /dev/null +++ b/crates/shirabe-symfony-console/src/application.rs @@ -0,0 +1,42 @@ +//! ref: composer/vendor/symfony/console/Application.php + +use crate::command::command::Command; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::CompletionSuggestions; +use crate::helper::helper_set::HelperSet; +use crate::input::input_definition::InputDefinition; +use indexmap::IndexMap; + +/// `Symfony\Component\Console\Application` is a concrete class in PHP, but it is ported here as a +/// trait rather than a struct. +/// Refer to shirabe::console::Application for the reason. +pub trait Application: std::fmt::Debug + shirabe_php_shim::AsAny { + fn get_name(&self) -> String; + + fn get_version(&self) -> String; + + fn get_help(&self) -> String; + + fn is_single_command(&self) -> bool; + + fn extract_namespace(&self, name: &str, limit: Option) -> String; + + fn find_namespace(&mut self, namespace: &str) -> anyhow::Result; + + fn all( + &mut self, + namespace: Option<&str>, + ) -> anyhow::Result>>>; + + fn find(&mut self, name: &str) -> anyhow::Result>>; + + fn get_definition(&mut self) -> std::rc::Rc>; + + fn get_helper_set(&mut self) -> std::rc::Rc>; + + fn complete( + &mut self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()>; +} diff --git a/crates/shirabe-symfony-console/src/attribute.rs b/crates/shirabe-symfony-console/src/attribute.rs new file mode 100644 index 00000000..241eb674 --- /dev/null +++ b/crates/shirabe-symfony-console/src/attribute.rs @@ -0,0 +1,3 @@ +pub mod as_command; + +pub use as_command::*; diff --git a/crates/shirabe-symfony-console/src/attribute/as_command.rs b/crates/shirabe-symfony-console/src/attribute/as_command.rs new file mode 100644 index 00000000..233e0b75 --- /dev/null +++ b/crates/shirabe-symfony-console/src/attribute/as_command.rs @@ -0,0 +1,36 @@ +//! ref: composer/vendor/symfony/console/Attribute/AsCommand.php + +/// Service tag to autoconfigure commands. +/// +/// PHP attribute: #[\Attribute(\Attribute::TARGET_CLASS)] +#[derive(Debug)] +pub struct AsCommand { + pub name: String, + pub description: Option, +} + +impl AsCommand { + pub fn new( + name: String, + description: Option, + aliases: Vec, + hidden: bool, + ) -> Self { + let mut this = Self { name, description }; + + if !hidden && aliases.is_empty() { + return this; + } + + let mut name: Vec = this.name.split('|').map(|s| s.to_string()).collect(); + name.extend(aliases); + + if hidden && !name[0].is_empty() { + name.insert(0, String::new()); + } + + this.name = name.join("|"); + + this + } +} diff --git a/crates/shirabe-symfony-console/src/color.rs b/crates/shirabe-symfony-console/src/color.rs new file mode 100644 index 00000000..13632b40 --- /dev/null +++ b/crates/shirabe-symfony-console/src/color.rs @@ -0,0 +1,222 @@ +//! ref: composer/vendor/symfony/console/Color.php + +use crate::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 { + COLORS.iter().find(|(k, _)| *k == name).map(|(_, v)| *v) +} + +fn bright_colors_get(name: &str) -> Option { + 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, Clone)] +pub struct Color { + foreground: String, + background: String, + // option name => ['set' => i64, 'unset' => i64] + options: IndexMap, +} + +impl Color { + pub fn new( + foreground: &str, + background: &str, + options: &[String], + ) -> Result { + 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::new(format!( + "Invalid option specified: \"{}\". Expected one of ({}).", + option.clone(), + shirabe_php_shim::implode( + ", ", + &AVAILABLE_OPTIONS + .iter() + .map(|(k, _)| k.to_string()) + .collect::>(), + ), + ))); + } + + 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 = 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(); + } + + format!("\u{1b}[{}m", shirabe_php_shim::implode(";", &set_codes)) + } + + pub fn unset(&self) -> String { + let mut unset_codes: Vec = 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(); + } + + format!("\u{1b}[{}m", shirabe_php_shim::implode(";", &unset_codes)) + } + + fn parse_color(color: &str, background: bool) -> Result { + 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 = 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::new(format!( + "Invalid \"{}\" color.", + color + ))); + } + + 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 = COLORS.iter().map(|(k, _)| k.to_string()).collect(); + available.extend(BRIGHT_COLORS.iter().map(|(k, _)| k.to_string())); + Err(InvalidArgumentException::new(format!( + "Invalid \"{}\" color; expected one of ({}).", + color, + shirabe_php_shim::implode(", ", &available), + ))) + } + + 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(std::ffi::OsStr::new("truecolor")) + { + return Self::degrade_hex_color_to_ansi(r, g, b).to_string(); + } + + format!("8;2;{};{};{}", r, g, b) + } + + 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-symfony-console/src/command.rs b/crates/shirabe-symfony-console/src/command.rs new file mode 100644 index 00000000..b17d7f5b --- /dev/null +++ b/crates/shirabe-symfony-console/src/command.rs @@ -0,0 +1,13 @@ +pub mod command; +pub mod complete_command; +pub mod dump_completion_command; +pub mod help_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 list_command::*; +pub use signalable_command_interface::*; diff --git a/crates/shirabe-symfony-console/src/command/command.rs b/crates/shirabe-symfony-console/src/command/command.rs new file mode 100644 index 00000000..f0d9dfe0 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/command.rs @@ -0,0 +1,897 @@ +//! ref: composer/vendor/symfony/console/Command/Command.php + +use crate::application::Application; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::CompletionSuggestions; +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::helper::helper_set::HelperSet; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::{self, OutputInterface}; +use indexmap::IndexMap; +use shirabe_php_shim::{PhpMixed, php_regex}; +use std::cell::{Cell, Ref}; + +/// The base-class state of the PHP `Command` class. +/// +/// The PHP `Command` class is split into the polymorphic [`Command`] trait (the +/// methods callers invoke on a command of unknown concrete type) and this struct, +/// which holds the base-class fields and provides their canonical behavior via +/// `impl Command for CommandData`. Subclasses embed a `CommandData` (directly, or +/// transitively through `BaseCommandData`) and forward the state methods to it. +/// +/// The mutable fields use interior mutability (`Cell`/`RefCell`) so that the `Command` +/// trait methods take `&self`, mirroring PHP's reference semantics: calling a method on a +/// command does not lock the object, so a command can be re-entered (e.g. the help command +/// describing itself) without the borrow conflicts a `&mut self` design would cause. +pub struct CommandData { + application: std::cell::RefCell>>>, + name: std::cell::RefCell>, + process_title: std::cell::RefCell>, + aliases: std::cell::RefCell>, + definition: std::cell::RefCell>, + hidden: Cell, + help: std::cell::RefCell, + description: std::cell::RefCell, + full_definition: std::cell::RefCell>, + ignore_validation_errors: Cell, + // A callable(InputInterface, OutputInterface) -> i64. + code: std::cell::RefCell< + Option PhpMixed>>, + >, + synopsis: std::cell::RefCell>, + usages: std::cell::RefCell>, + helper_set: std::cell::RefCell>>>, +} + +impl CommandData { + // see https://tldp.org/LDP/abs/html/exitcodes.html + pub const SUCCESS: i64 = 0; + pub const FAILURE: i64 = 1; + pub const INVALID: i64 = 2; + + /// The default command name. + // NOTE: PHP `protected static $defaultName`; static late-binding property. + pub const DEFAULT_NAME: Option<&'static str> = None; + + /// The default command description. + // NOTE: PHP `protected static $defaultDescription`; static late-binding property. + pub const DEFAULT_DESCRIPTION: Option<&'static str> = None; + + pub fn get_default_name() -> Option { + // TODO(phase-c): PHP uses ReflectionClass to read the #[AsCommand] attribute + // and ReflectionProperty to check that `$defaultName` is declared on the late-static + // class itself (not inherited). Reflection-based late static binding has no direct + // Rust equivalent; human review needed for the porting strategy. + todo!() + } + + pub fn get_default_description() -> Option { + // TODO(phase-c): same Reflection/late-static-binding concern as get_default_name(). + todo!() + } + + /// Builds the base-class state. `name` is the name of the command; passing None + /// means it must be set in the subclass `configure()`. + /// + /// Unlike PHP's `__construct`, this does not call `configure()` — the concrete + /// command's `new()` calls `configure()` after embedding the data, mirroring the + /// virtual dispatch of `$this->configure()` from the parent constructor. + pub fn new(name: Option) -> Self { + let this = CommandData { + application: std::cell::RefCell::new(None), + name: std::cell::RefCell::new(None), + process_title: std::cell::RefCell::new(None), + aliases: std::cell::RefCell::new(Vec::new()), + definition: std::cell::RefCell::new(Some( + InputDefinition::new(Vec::new()).expect("an empty InputDefinition cannot fail"), + )), + hidden: Cell::new(false), + help: std::cell::RefCell::new(String::new()), + description: std::cell::RefCell::new(String::new()), + full_definition: std::cell::RefCell::new(None), + ignore_validation_errors: Cell::new(false), + code: std::cell::RefCell::new(None), + synopsis: std::cell::RefCell::new(IndexMap::new()), + usages: std::cell::RefCell::new(Vec::new()), + helper_set: std::cell::RefCell::new(None), + }; + + // PHP's __construct also derives the name from getDefaultName() when null and + // sets the default description; both rely on Reflection late-static-binding + // (get_default_name/get_default_description are todo!()), and concrete commands + // always set their name in configure(), so only an explicit name is honored here. + if let Some(name) = name { + *this.name.borrow_mut() = Some(name); + } + + this + } + + /// Applies a `$defaultName`-style name (PHP `Command::__construct` when `$name` is null and a + /// `static $defaultName` exists). The string is `|`-separated; a leading empty segment marks the + /// command hidden, the next segment is the name, and the rest are aliases. + pub fn apply_default_name(&self, default_name: &str) -> anyhow::Result<()> { + let mut aliases: Vec = default_name.split('|').map(|s| s.to_string()).collect(); + let mut name = aliases.remove(0); + if name.is_empty() { + self.set_hidden(true); + name = if aliases.is_empty() { + String::new() + } else { + aliases.remove(0) + }; + } + self.set_name(&name)?; + self.set_aliases(aliases)?; + Ok(()) + } + + /// Validates a command name. + /// + /// It must be non-empty and parts can optionally be separated by ":". + /// + /// Throws InvalidArgumentException when the name is invalid. + fn validate_name(&self, name: &str) -> anyhow::Result> { + let mut matches: Vec> = Vec::new(); + if !shirabe_php_shim::preg_match(php_regex!(r"/^[^\:]++(\:[^\:]++)*$/"), name, &mut matches) + { + return Ok(Err(InvalidArgumentException::new(format!( + "Command name \"{}\" is invalid.", + name + )))); + } + + Ok(Ok(())) + } + + /// Sets an array of argument and option instances (the Symfony-typed entry point; + /// `BaseCommand::set_definition` adapts the Composer-typed arguments to this). + pub fn set_definition(&self, definition: SetDefinitionArg) -> &Self { + match definition { + SetDefinitionArg::Definition(definition) => { + *self.definition.borrow_mut() = Some(definition); + } + SetDefinitionArg::Array(definition) => { + let _ = self + .definition + .borrow_mut() + .as_mut() + .unwrap() + .set_definition(definition); + } + } + + *self.full_definition.borrow_mut() = None; + + self + } + + /// Adds an argument (Symfony-typed entry point). + /// + /// Throws InvalidArgumentException when argument mode is not valid. + pub fn add_argument( + &self, + name: &str, + mode: Option, + description: &str, + default: PhpMixed, + ) -> anyhow::Result<&Self> { + self.definition + .borrow_mut() + .as_mut() + .unwrap() + .add_argument(InputArgument::new( + name.to_string(), + mode, + description.to_string(), + default.clone(), + )?)?; + if let Some(full_definition) = self.full_definition.borrow_mut().as_mut() { + full_definition.add_argument(InputArgument::new( + name.to_string(), + mode, + description.to_string(), + default, + )?)?; + } + + Ok(self) + } + + /// Adds an option (Symfony-typed entry point). + /// + /// Throws InvalidArgumentException if option mode is invalid or incompatible. + pub fn add_option( + &self, + name: &str, + shortcut: PhpMixed, + mode: Option, + description: &str, + default: PhpMixed, + ) -> anyhow::Result<&Self> { + self.definition + .borrow_mut() + .as_mut() + .unwrap() + .add_option(InputOption::new( + name, + shortcut.clone(), + mode, + description.to_string(), + default.clone(), + )?)?; + if let Some(full_definition) = self.full_definition.borrow_mut().as_mut() { + full_definition.add_option(InputOption::new( + name, + shortcut, + mode, + description.to_string(), + default, + )?)?; + } + + Ok(self) + } +} + +/// The argument of `CommandData::set_definition()`, which accepts either an array of +/// argument/option instances or an InputDefinition. +#[derive(Debug)] +pub enum SetDefinitionArg { + Array(Vec), + Definition(InputDefinition), +} + +/// Forwards a single trait method to an embedded field that already implements the +/// method (the "inner" command-state holder). +/// +/// Each `Command`/`BaseCommand` implementer spells out the methods it delegates, one +/// `delegate_to_inner!` per method, alongside the few methods it overrides by hand. +/// The first argument names the field to forward to; the second is the method's +/// signature. Every method takes `&self` (the command state is interior-mutable); +/// fluent setters returning `&Self` (optionally wrapped in `anyhow::Result`) are handled +/// specially so the returned reference is re-rooted at the outer `self` rather than the +/// inner field. +#[macro_export] +macro_rules! delegate_to_inner { + // fluent fallible: -> anyhow::Result<&Self> + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?) -> anyhow::Result<&Self>) => { + fn $name(&self $(, $arg: $ty)*) -> anyhow::Result<&Self> { + self.$field.$name($($arg),*)?; + Ok(self) + } + }; + // fluent infallible: -> &Self + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?) -> &Self) => { + fn $name(&self $(, $arg: $ty)*) -> &Self { + self.$field.$name($($arg),*); + self + } + }; + // &self with a return type + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?) -> $ret:ty) => { + fn $name(&self $(, $arg: $ty)*) -> $ret { + self.$field.$name($($arg),*) + } + }; + // &self without a return type + ($field:ident, fn $name:ident(&self $(, $arg:ident : $ty:ty )* $(,)?)) => { + fn $name(&self $(, $arg: $ty)*) { + self.$field.$name($($arg),*) + } + }; +} + +/// Forwards every `Command` state method (the setters/getters whose canonical impl lives on +/// `CommandData` and which no subclass overrides) to an embedded field. Each command invokes +/// this once inside its `impl Command` block and spells out by hand only the behavior hooks it +/// overrides (`configure`/`execute`/`initialize`/...). The single argument names the field to +/// forward to (`inner` for Symfony commands, `base_command_data` for Composer commands). +#[macro_export] +macro_rules! delegate_command_trait_impls_to_inner { + ($field:ident) => { + $crate::delegate_to_inner!($field, fn is_enabled(&self) -> bool); + $crate::delegate_to_inner!($field, fn set_application(&self, application: Option>>)); + $crate::delegate_to_inner!($field, fn get_application(&self) -> Option>>); + $crate::delegate_to_inner!($field, fn set_helper_set(&self, helper_set: std::rc::Rc>)); + $crate::delegate_to_inner!($field, fn get_helper_set(&self) -> Option>>); + $crate::delegate_to_inner!($field, fn merge_application_definition(&self, merge_args: bool)); + $crate::delegate_to_inner!($field, fn get_definition(&self) -> std::cell::Ref<'_, $crate::input::input_definition::InputDefinition>); + $crate::delegate_to_inner!($field, fn get_native_definition(&self) -> std::cell::Ref<'_, $crate::input::input_definition::InputDefinition>); + $crate::delegate_to_inner!($field, fn set_name(&self, name: &str) -> anyhow::Result<()>); + $crate::delegate_to_inner!($field, fn get_name(&self) -> Option); + $crate::delegate_to_inner!($field, fn set_process_title(&self, title: &str)); + $crate::delegate_to_inner!($field, fn get_process_title(&self) -> Option); + $crate::delegate_to_inner!($field, fn set_hidden(&self, hidden: bool)); + $crate::delegate_to_inner!($field, fn is_hidden(&self) -> bool); + $crate::delegate_to_inner!($field, fn set_description(&self, description: &str)); + $crate::delegate_to_inner!($field, fn get_description(&self) -> String); + $crate::delegate_to_inner!($field, fn set_help(&self, help: &str)); + $crate::delegate_to_inner!($field, fn get_help(&self) -> String); + $crate::delegate_to_inner!($field, fn get_processed_help(&self) -> String); + $crate::delegate_to_inner!($field, fn set_aliases(&self, aliases: Vec) -> anyhow::Result<()>); + $crate::delegate_to_inner!($field, fn get_aliases(&self) -> Vec); + $crate::delegate_to_inner!($field, fn get_synopsis(&self, short: bool) -> String); + $crate::delegate_to_inner!($field, fn add_usage(&self, usage: &str)); + $crate::delegate_to_inner!($field, fn get_usages(&self) -> Vec); + $crate::delegate_to_inner!($field, fn get_helper(&self, name: &str) -> anyhow::Result>); + $crate::delegate_to_inner!($field, fn set_code(&self, code: Box shirabe_php_shim::PhpMixed>)); + $crate::delegate_to_inner!($field, fn get_code(&self) -> std::cell::Ref<'_, Option shirabe_php_shim::PhpMixed>>>); + $crate::delegate_to_inner!($field, fn ignore_validation_errors(&self)); + $crate::delegate_to_inner!($field, fn get_ignore_validation_errors(&self) -> bool); + }; +} + +/// Polymorphic interface for all commands (PHP's `Command` base class as seen by +/// callers that hold a command of unknown concrete type). +/// +/// The canonical behavior lives in `impl Command for CommandData`; subclasses forward +/// the state methods there and override the behavior hooks (`configure`/`execute`/...). +/// Object-safe so `dyn Command` works. All methods take `&self`; the command's mutable +/// state is interior-mutable (see [`CommandData`]). +pub trait Command: std::fmt::Debug + shirabe_php_shim::AsAny + shirabe_php_shim::PhpClass { + /// Configures the current command. + fn configure(&self) -> anyhow::Result<()> { + Ok(()) + } + + /// Executes the current command, returning 0 or an exit code. + /// + /// Concrete commands override this; reaching the default means a command class + /// forgot to implement it (PHP throws LogicException — a programming error here). + fn execute( + &self, + _input: std::rc::Rc>, + _output: std::rc::Rc>, + ) -> anyhow::Result { + panic!("You must override the execute() method in the concrete command class."); + } + + /// Interacts with the user before the InputDefinition is validated. + fn interact( + &self, + _input: std::rc::Rc>, + _output: std::rc::Rc>, + ) { + } + + /// Initializes the command after the input has been bound and before it is validated. + fn initialize( + &self, + _input: std::rc::Rc>, + _output: std::rc::Rc>, + ) -> anyhow::Result<()> { + Ok(()) + } + + /// Adds suggestions to `suggestions` for the current completion input. + /// + /// PHP's `complete` is `void` but can throw; errors are surfaced through `anyhow::Result` + /// so they propagate to `CompleteCommand::execute`'s catch-all (which turns them into + /// exit code 2), matching the PHP exception flow. + fn complete( + &self, + _input: &CompletionInput, + _suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + Ok(()) + } + + /// Whether this command proxies to another application/command (Composer's + /// `BaseCommand::isProxyCommand`). Exposed here so the `dyn Command` registry can detect proxy + /// commands without downcasting to the Composer `BaseCommand` trait; defaults to `false` and is + /// overridden by Composer proxy commands such as `GlobalCommand`. + fn is_proxy_command(&self) -> bool { + false + } + + /// Runs the command. + /// + /// Template method: it calls `self.initialize()`, `self.interact()` and + /// `self.execute()`, which dispatch to the concrete command's overrides. It must + /// not be overridden (except by proxy commands like `GlobalCommand`) nor delegated, + /// or that late binding breaks. + fn run( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + self.base_run(input, output) + } + + /// The base-class (`Command`) body of `run`, as PHP's `Command::run`. Proxy commands such as + /// `GlobalCommand` override `run` but still call `base_run` to delegate to the base behavior, + /// matching PHP's `parent::run($input, $output)`. It must not be overridden, or the late + /// binding of `initialize`/`interact`/`execute` to the concrete command breaks. + fn base_run( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + // add the application arguments and options + self.merge_application_definition(true); + + // bind the input against the command specific arguments/options + match input.borrow_mut().bind(&self.get_definition()) { + Ok(()) => {} + Err(e) => { + if !self.get_ignore_validation_errors() { + return Err(e); + } + } + } + + self.initialize(input.clone(), output.clone())?; + + if let Some(process_title) = self.get_process_title() { + // TODO(phase-c): PHP probes for cli_set_process_title / setproctitle availability. + if shirabe_php_shim::function_exists("cli_set_process_title") { + if !shirabe_php_shim::cli_set_process_title(&process_title) { + if shirabe_php_shim::PHP_OS == "Darwin" { + output.borrow_mut().writeln( + &["Running \"cli_set_process_title\" as an unprivileged user is not supported on MacOS.".to_string()], + output_interface::VERBOSITY_VERY_VERBOSE, + ); + } else { + shirabe_php_shim::cli_set_process_title(&process_title); + } + } + } else if shirabe_php_shim::function_exists("setproctitle") { + shirabe_php_shim::setproctitle(&process_title); + } else if output.borrow().get_verbosity() == output_interface::VERBOSITY_VERY_VERBOSE { + output.borrow_mut().writeln( + &["Install the proctitle PECL to be able to change the process title.".to_string()], + output_interface::OUTPUT_NORMAL, + ); + } + } + + if input.borrow().is_interactive() { + self.interact(input.clone(), output.clone()); + } + + // The command name argument is often omitted when a command is executed directly with its run() method. + // It would fail the validation if we didn't make sure the command argument is present, + // since it's required by the application. + if input.borrow().has_argument("command") + && matches!(input.borrow().get_argument("command")?, PhpMixed::Null) + { + let name = self.get_name(); + input + .borrow_mut() + .set_argument("command", PhpMixed::from(name))?; + } + + input.borrow_mut().validate()?; + + let status_code: PhpMixed = if self.get_code().is_some() { + let code = self.get_code(); + let code = code.as_ref().unwrap(); + code(&mut *input.borrow_mut(), &mut *output.borrow_mut()) + } else { + let executed = self.execute(input.clone(), output.clone())?; + // PHP also raises \TypeError when execute() does not return int; in this + // strongly-typed port execute() already returns an int, so the check is moot. + PhpMixed::from(executed) + }; + + // is_numeric($statusCode) ? (int) $statusCode : 0 + Ok(shirabe_php_shim::is_numeric_to_int(&status_code)) + } + + // --- state methods (canonical impl on `CommandData`; subclasses forward there) --- + + fn is_enabled(&self) -> bool; + + fn set_application( + &self, + application: Option>>, + ); + + fn get_application(&self) -> Option>>; + + fn set_helper_set(&self, helper_set: std::rc::Rc>); + + fn get_helper_set(&self) -> Option>>; + + fn merge_application_definition(&self, merge_args: bool); + + fn get_definition(&self) -> Ref<'_, InputDefinition>; + + fn get_native_definition(&self) -> Ref<'_, InputDefinition>; + + fn set_name(&self, name: &str) -> anyhow::Result<()>; + + fn get_name(&self) -> Option; + + fn set_process_title(&self, title: &str); + + fn get_process_title(&self) -> Option; + + fn set_hidden(&self, hidden: bool); + + fn is_hidden(&self) -> bool; + + fn set_description(&self, description: &str); + + fn get_description(&self) -> String; + + fn set_help(&self, help: &str); + + fn get_help(&self) -> String; + + fn get_processed_help(&self) -> String; + + fn set_aliases(&self, aliases: Vec) -> anyhow::Result<()>; + + fn get_aliases(&self) -> Vec; + + fn get_synopsis(&self, short: bool) -> String; + + fn add_usage(&self, usage: &str); + + fn get_usages(&self) -> Vec; + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result>; + + fn set_code( + &self, + code: Box PhpMixed>, + ); + + fn get_code( + &self, + ) -> Ref<'_, Option PhpMixed>>>; + + fn ignore_validation_errors(&self); + + fn get_ignore_validation_errors(&self) -> bool; +} + +impl shirabe_php_shim::PhpClass for CommandData { + fn php_class_name(&self) -> String { + panic!( + "php_class_name called on the base command state; concrete commands supply their class name" + ); + } +} + +impl Command for CommandData { + fn is_enabled(&self) -> bool { + true + } + + fn set_application( + &self, + application: Option>>, + ) { + *self.application.borrow_mut() = application.clone(); + if let Some(application) = application { + self.set_helper_set(application.borrow_mut().get_helper_set()); + } else { + *self.helper_set.borrow_mut() = None; + } + + *self.full_definition.borrow_mut() = None; + } + + fn get_application(&self) -> Option>> { + self.application.borrow().clone() + } + + fn set_helper_set(&self, helper_set: std::rc::Rc>) { + *self.helper_set.borrow_mut() = Some(helper_set); + } + + fn get_helper_set(&self) -> Option>> { + self.helper_set.borrow().clone() + } + + /// Merges the application definition with the command definition. + fn merge_application_definition(&self, merge_args: bool) { + let application = match &*self.application.borrow() { + None => return, + Some(application) => application.clone(), + }; + + // InputDefinition stores its entries as `Rc` / `Rc` while the + // setters take owned values, so the shared entries are cloned out (both types derive Clone). + let app_definition = application.borrow_mut().get_definition(); + + let mut full_definition = + InputDefinition::new(Vec::new()).expect("an empty InputDefinition cannot fail"); + + let own_options: Vec = self + .definition + .borrow() + .as_ref() + .unwrap() + .get_options() + .values() + .map(|option| (**option).clone()) + .collect(); + full_definition + .set_options(own_options) + .expect("the command's own options are already valid"); + + let app_options: Vec = app_definition + .borrow() + .get_options() + .values() + .map(|option| (**option).clone()) + .collect(); + full_definition + .add_options(app_options) + .expect("merging the application options cannot conflict here"); + + if merge_args { + let app_arguments: Vec = app_definition + .borrow() + .get_arguments() + .values() + .map(|argument| (**argument).clone()) + .collect(); + full_definition + .set_arguments(app_arguments) + .expect("the application arguments are already valid"); + + let own_arguments: Vec = self + .definition + .borrow() + .as_ref() + .unwrap() + .get_arguments() + .values() + .map(|argument| (**argument).clone()) + .collect(); + full_definition + .add_arguments(Some(own_arguments)) + .expect("merging the command's own arguments cannot conflict here"); + } else { + let own_arguments: Vec = self + .definition + .borrow() + .as_ref() + .unwrap() + .get_arguments() + .values() + .map(|argument| (**argument).clone()) + .collect(); + full_definition + .set_arguments(own_arguments) + .expect("the command's own arguments are already valid"); + } + + *self.full_definition.borrow_mut() = Some(full_definition); + } + + fn get_definition(&self) -> Ref<'_, InputDefinition> { + if self.full_definition.borrow().is_some() { + Ref::map(self.full_definition.borrow(), |full_definition| { + full_definition.as_ref().unwrap() + }) + } else { + self.get_native_definition() + } + } + + fn get_native_definition(&self) -> Ref<'_, InputDefinition> { + Ref::map(self.definition.borrow(), |definition| match definition { + Some(definition) => definition, + None => { + // PHP throws LogicException; `definition` is set in `new()`, so None is a + // programming error (forgot to call the parent constructor). + panic!( + "Command class is not correctly initialized. You probably forgot to call the parent constructor." + ); + } + }) + } + + fn set_name(&self, name: &str) -> anyhow::Result<()> { + if let Err(e) = self.validate_name(name)? { + return Err(e.into()); + } + + *self.name.borrow_mut() = Some(name.to_string()); + + Ok(()) + } + + fn get_name(&self) -> Option { + self.name.borrow().clone() + } + + fn set_process_title(&self, title: &str) { + *self.process_title.borrow_mut() = Some(title.to_string()); + } + + fn get_process_title(&self) -> Option { + self.process_title.borrow().clone() + } + + fn set_hidden(&self, hidden: bool) { + self.hidden.set(hidden); + } + + fn is_hidden(&self) -> bool { + self.hidden.get() + } + + fn set_description(&self, description: &str) { + *self.description.borrow_mut() = description.to_string(); + } + + fn get_description(&self) -> String { + self.description.borrow().clone() + } + + fn set_help(&self, help: &str) { + *self.help.borrow_mut() = help.to_string(); + } + + fn get_help(&self) -> String { + self.help.borrow().clone() + } + + fn get_processed_help(&self) -> String { + let name = self.name.borrow().clone(); + let is_single_command = match &*self.application.borrow() { + Some(application) => application.borrow().is_single_command(), + None => false, + }; + + let placeholders = [ + "%command.name%".to_string(), + "%command.full_name%".to_string(), + ]; + let php_self = shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .php_self() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let replacements = [ + name.clone().unwrap_or_default(), + if is_single_command { + php_self + } else { + format!("{} {}", php_self, name.unwrap_or_default()) + }, + ]; + + let help = self.get_help(); + let subject = if help.is_empty() { + self.get_description() + } else { + help + }; + + shirabe_php_shim::str_replace_array(&placeholders, &replacements, &subject) + } + + fn set_aliases(&self, aliases: Vec) -> anyhow::Result<()> { + let mut list = Vec::new(); + + for alias in &aliases { + if let Err(e) = self.validate_name(alias)? { + return Err(e.into()); + } + list.push(alias.clone()); + } + + // PHP: `\is_array($aliases) ? $aliases : $list`. Here `aliases` is always an + // array (Vec), so the result is `aliases`; `list` mirrors the validation loop. + *self.aliases.borrow_mut() = aliases; + + Ok(()) + } + + fn get_aliases(&self) -> Vec { + self.aliases.borrow().clone() + } + + fn get_synopsis(&self, short: bool) -> String { + let key = if short { "short" } else { "long" }.to_string(); + + if !self.synopsis.borrow().contains_key(&key) { + let value = format!( + "{} {}", + self.name.borrow().clone().unwrap_or_default(), + self.definition + .borrow() + .as_ref() + .unwrap() + .get_synopsis(short) + ) + .trim() + .to_string(); + self.synopsis.borrow_mut().insert(key.clone(), value); + } + + self.synopsis.borrow()[&key].clone() + } + + fn add_usage(&self, usage: &str) { + let mut usage = usage.to_string(); + let name = self.name.borrow().clone().unwrap_or_default(); + if !usage.starts_with(&name) { + usage = format!("{} {}", name, usage); + } + + self.usages.borrow_mut().push(usage); + } + + fn get_usages(&self) -> Vec { + self.usages.borrow().clone() + } + + fn get_helper( + &self, + name: &str, + ) -> anyhow::Result> { + let helper_set_ref = self.helper_set.borrow(); + let helper_set = match &*helper_set_ref { + None => { + return Ok(Err(crate::exception::logic_exception::LogicException::new( + format!( + "Cannot retrieve helper \"{}\" because there is no HelperSet defined. Did you forget to add your command to the application or to set the application on the command using the setApplication() method? You can also set the HelperSet directly using the setHelperSet() method.", + name + ), + ))); + } + Some(helper_set) => helper_set, + }; + + // TODO(plugin): PHP's Command::getHelper($name) looks a helper up by string via + // HelperSet::get($name). The HelperSet is now a closed set exposing only typed getters + // (get_formatter/get_question/...), so a string-keyed lookup no longer exists. Callers + // should use the typed getters on the HelperSet directly; restoring name-based lookup is + // deferred until the plugin API (which is the only source of dynamically named helpers). + let _ = helper_set; + todo!() + } + + fn set_code( + &self, + code: Box PhpMixed>, + ) { + // TODO(php-runtime): PHP rebinds an unbound Closure's $this to the command instance via + // ReflectionFunction/Closure::bind. Rust closures have no `$this` rebinding; + // the closure is stored as-is. + *self.code.borrow_mut() = Some(code); + } + + fn get_code( + &self, + ) -> Ref<'_, Option PhpMixed>>> + { + self.code.borrow() + } + + fn ignore_validation_errors(&self) { + self.ignore_validation_errors.set(true); + } + + fn get_ignore_validation_errors(&self) -> bool { + self.ignore_validation_errors.get() + } +} + +impl std::fmt::Debug for CommandData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CommandData") + .field("name", &self.name.borrow()) + .field("aliases", &self.aliases.borrow()) + .field("hidden", &self.hidden.get()) + .field("description", &self.description.borrow()) + .finish_non_exhaustive() + } +} diff --git a/crates/shirabe-symfony-console/src/command/complete_command.rs b/crates/shirabe-symfony-console/src/command/complete_command.rs new file mode 100644 index 00000000..3dad2609 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/complete_command.rs @@ -0,0 +1,415 @@ +//! ref: composer/vendor/symfony/console/Command/CompleteCommand.php + +use crate::command::command::{Command, CommandData}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::completion::output::bash_completion_output::BashCompletionOutput; +use crate::completion::output::completion_output_interface::CompletionOutputInterface; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use std::ops::{Deref, DerefMut}; + +/// Responsible for providing the values to the shell completion. +#[derive(Debug)] +pub struct CompleteCommand { + inner: CommandData, + completion_outputs: IndexMap, + is_debug: std::cell::Cell, +} + +impl_php_class!( + CompleteCommand, + r"Symfony\Component\Console\Command\CompleteCommand" +); + +impl Deref for CompleteCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for CompleteCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl CompleteCommand { + pub const DEFAULT_NAME: &'static str = "|_complete"; + pub const DEFAULT_DESCRIPTION: &'static str = + "Internal command to provide shell completion suggestions"; + + /// @param completion_outputs A list of additional completion outputs, with shell name as + /// key and FQCN as value + pub fn new(completion_outputs: IndexMap) -> anyhow::Result { + // must be set before the parent constructor, as the property value is used in configure() + let mut completion_outputs = completion_outputs; + // $completionOutputs + ['bash' => BashCompletionOutput::class] + completion_outputs + .entry("bash".to_string()) + .or_insert_with(|| { + PhpMixed::from( + "Symfony\\Component\\Console\\Completion\\Output\\BashCompletionOutput" + .to_string(), + ) + }); + + let this = Self { + inner: CommandData::new(None), + completion_outputs, + is_debug: std::cell::Cell::new(false), + }; + // PHP: static $defaultName = '|_complete' / $defaultDescription, applied by the parent + // constructor before configure(). + this.inner.apply_default_name(Self::DEFAULT_NAME)?; + this.inner.set_description(Self::DEFAULT_DESCRIPTION); + this.configure()?; + + Ok(this) + } + + fn create_completion_input( + &self, + input: &dyn InputInterface, + ) -> anyhow::Result { + let current_index = input.get_option("current")?; + if !current_index.to_bool() || !shirabe_php_shim::ctype_digit(¤t_index.to_string()) { + anyhow::bail!(shirabe_php_shim::RuntimeException::new( + "The \"--current\" option must be set and it must be an integer.".to_string() + )); + } + + let tokens: Vec = match input.get_option("input")?.as_list() { + Some(list) => list.iter().map(|v| v.to_string()).collect(), + None => Vec::new(), + }; + let mut completion_input = CompletionInput::from_tokens( + tokens, + current_index.to_string().parse::().unwrap_or(0), + )?; + + // try { $completionInput->bind(...); } catch (ExceptionInterface $e) {} + let application = self.get_application().unwrap(); + let definition = application.borrow_mut().get_definition(); + let _ = completion_input.bind(&definition.borrow()); + + Ok(completion_input) + } + + fn find_command( + &self, + completion_input: &CompletionInput, + _output: &dyn OutputInterface, + ) -> Option>> { + // try { ... } catch (CommandNotFoundException $e) {} + let input_name = completion_input.get_first_argument()?; + + let application = self.get_application().unwrap(); + // CommandNotFoundException is caught and swallowed by returning None. + application.borrow_mut().find(&input_name).ok() + } + + fn log(&self, messages: &str) { + self.log_many(vec![messages.to_string()]); + } + + fn log_many(&self, messages: Vec) { + if !self.is_debug.get() { + return; + } + + let command_name = shirabe_php_shim::basename( + &shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .argv() + .next() + .unwrap_or_default() + .to_string_lossy(), + ); + shirabe_php_shim::file_put_contents3( + &format!( + "{}/sf_{}.log", + shirabe_php_shim::sys_get_temp_dir(), + command_name + ), + &(messages.join(shirabe_php_shim::PHP_EOL) + shirabe_php_shim::PHP_EOL), + shirabe_php_shim::FILE_APPEND, + ); + } +} + +fn get_class_of_command(command: &std::rc::Rc>) -> String { + // LazyCommand is intentionally not ported. + command.borrow().php_class_name() +} + +fn get_definition_options( + command: &std::rc::Rc>, +) -> Vec> { + command + .borrow() + .get_definition() + .get_options() + .values() + .cloned() + .collect() +} + +/// new $completionOutput(); +fn instantiate_completion_output(class: &PhpMixed) -> Box { + match class.to_string().as_str() { + "Symfony\\Component\\Console\\Completion\\Output\\BashCompletionOutput" => { + Box::new(BashCompletionOutput) + } + // completion_outputs only ever registers the bash output (Composer registers no extra + // ones), so any other FQCN is a programming error. + other => panic!("unknown completion output class: {}", other), + } +} + +impl Command for CompleteCommand { + fn configure(&self) -> anyhow::Result<()> { + let shells = self + .completion_outputs + .keys() + .cloned() + .collect::>() + .join("\", \""); + self.inner + .add_option( + "shell", + PhpMixed::from("s".to_string()), + Some(InputOption::VALUE_REQUIRED), + &format!("The shell type (\"{}\")", shells), + PhpMixed::Null, + )? + .add_option( + "input", + PhpMixed::from("i".to_string()), + Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + "An array of input tokens (e.g. COMP_WORDS or argv)", + PhpMixed::Null, + )? + .add_option( + "current", + PhpMixed::from("c".to_string()), + Some(InputOption::VALUE_REQUIRED), + "The index of the \"input\" array that the cursor is in (e.g. COMP_CWORD)", + PhpMixed::Null, + )? + .add_option( + "symfony", + PhpMixed::from("S".to_string()), + Some(InputOption::VALUE_REQUIRED), + "The version of the completion script", + PhpMixed::Null, + )?; + + Ok(()) + } + + fn initialize( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + let _ = (input, output); + self.is_debug.set(shirabe_php_shim::filter_var_boolean( + &shirabe_php_shim::getenv("SYMFONY_COMPLETION_DEBUG") + .unwrap_or_default() + .to_string_lossy(), + )); + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + // try { ... } catch (\Throwable $e) { ...; if ($output->isDebug()) { throw $e; } return 2; } + let result: anyhow::Result = (|| { + // uncomment when a bugfix or BC break has been introduced in the shell completion scripts + // $version = $input->getOption('symfony'); + // if ($version && version_compare($version, 'x.y', '>=')) { + // $message = sprintf('Completion script version is not supported ("%s" given, ">=x.y" required).', $version); + // $this->log($message); + // $output->writeln($message.' Install the Symfony completion script again by using the "completion" command.'); + // return 126; + // } + + let shell = input.borrow().get_option("shell")?; + if !shell.to_bool() { + anyhow::bail!(shirabe_php_shim::RuntimeException::new( + "The \"--shell\" option must be set.".to_string() + )); + } + + let completion_output = self + .completion_outputs + .get(&shell.to_string()) + .cloned() + .unwrap_or(PhpMixed::Bool(false)); + if !completion_output.to_bool() { + anyhow::bail!(shirabe_php_shim::RuntimeException::new(format!( + "Shell completion is not supported for your shell: \"{}\" (supported: \"{}\").", + shell, + self.completion_outputs + .keys() + .cloned() + .collect::>() + .join("\", \"") + ))); + } + + let mut completion_input = self.create_completion_input(&*input.borrow())?; + let mut suggestions = CompletionSuggestions::new(); + + self.log_many(vec![ + String::new(), + format!( + "{}", + shirabe_php_shim::date("Y-m-d H:i:s", None) + ), + "Input: (\"|\" indicates the cursor position)".to_string(), + format!(" {}", completion_input.to_string()), + "Command:".to_string(), + format!( + " {}", + shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .argv() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>() + .join(" ") + ), + "Messages:".to_string(), + ]); + + let command = self.find_command(&completion_input, &*output.borrow()); + match command { + None => { + self.log(" No command found, completing using the Application class."); + + let application = self.get_application().unwrap(); + application + .borrow_mut() + .complete(&completion_input, &mut suggestions)?; + } + Some(command) + if completion_input.must_suggest_argument_values_for("command") + && command.borrow().get_name().as_deref() + != Some(&completion_input.get_completion_value()) + && !command + .borrow() + .get_aliases() + .iter() + .any(|a| a == &completion_input.get_completion_value()) => + { + self.log(" No command found, completing using the Application class."); + + // expand shortcut names ("cache:cl") into their full name ("cache:clear") + let mut values = vec![command.borrow().get_name()]; + values.extend(command.borrow().get_aliases().into_iter().map(Some)); + suggestions.suggest_values( + values + .into_iter() + .flatten() + .filter(|v| !v.is_empty()) + .map(StringOrSuggestion::String) + .collect(), + ); + } + Some(command) => { + // PHP: $command->mergeApplicationDefinition() — $mergeArgs defaults to true. + command.borrow().merge_application_definition(true); + completion_input.bind(&command.borrow().get_definition())?; + + if CompletionInput::TYPE_OPTION_NAME == completion_input.get_completion_type() { + self.log(&format!( + " Completing option names for the {} command.", + get_class_of_command(&command) + )); + + suggestions.suggest_options(get_definition_options(&command)); + } else { + self.log_many(vec![ + format!( + " Completing using the {} class.", + get_class_of_command(&command) + ), + format!( + " Completing {} for {}", + completion_input.get_completion_type(), + completion_input.get_completion_name().unwrap_or_default() + ), + ]); + let compval = completion_input.get_completion_value(); + if !compval.is_empty() { + self.log(&format!(" Current value: {}", compval)); + } + + command + .borrow() + .complete(&completion_input, &mut suggestions)?; + } + } + } + + // $completionOutput = new $completionOutput(); + let completion_output: Box = + instantiate_completion_output(&completion_output); + + self.log("Suggestions:"); + let option_suggestions = suggestions.get_option_suggestions(); + if !option_suggestions.is_empty() { + self.log(&format!( + " --{}", + option_suggestions + .iter() + .map(|o| o.get_name()) + .collect::>() + .join(" --") + )); + } else { + let value_suggestions: Vec = suggestions + .get_value_suggestions() + .iter() + .map(|s| s.get_value()) + .collect(); + if !value_suggestions.is_empty() { + self.log(&format!(" {}", value_suggestions.join(" "))); + } else { + self.log(" No suggestions were provided"); + } + } + + completion_output.write(&suggestions, &*output.borrow_mut()); + + Ok(0) + })(); + + match result { + Ok(code) => Ok(code), + Err(e) => { + self.log_many(vec!["Error!".to_string(), format!("{}", e)]); + + if output.borrow().is_debug() { + return Err(e); + } + + Ok(2) + } + } + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/dump_completion_command.rs b/crates/shirabe-symfony-console/src/command/dump_completion_command.rs new file mode 100644 index 00000000..efe116a9 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/dump_completion_command.rs @@ -0,0 +1,295 @@ +//! ref: composer/vendor/symfony/console/Command/DumpCompletionCommand.php + +use crate::command::command::{Command, CommandData}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::input::input_argument::InputArgument; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::{self, OutputInterface}; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use shirabe_symfony_process::process::Process; +use std::ops::{Deref, DerefMut}; + +/// __DIR__.'/../Resources/completion.bash', embedded at compile time (this port ships as a +/// single binary and does not install the Resources directory alongside it). +const COMPLETION_BASH: &str = include_str!("../Resources/completion.bash"); + +/// Dumps the completion script for the current shell. +#[derive(Debug)] +pub struct DumpCompletionCommand { + inner: CommandData, +} + +impl_php_class!( + DumpCompletionCommand, + r"Symfony\Component\Console\Command\DumpCompletionCommand" +); + +impl Deref for DumpCompletionCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for DumpCompletionCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Default for DumpCompletionCommand { + fn default() -> Self { + Self::new() + } +} + +impl DumpCompletionCommand { + pub const DEFAULT_NAME: &'static str = "completion"; + pub const DEFAULT_DESCRIPTION: &'static str = "Dump the shell completion script"; + + pub fn new() -> Self { + let command = DumpCompletionCommand { + inner: CommandData::new(None), + }; + // PHP: static $defaultName = 'completion' / $defaultDescription, applied by the parent + // constructor before configure(). + command + .inner + .apply_default_name(Self::DEFAULT_NAME) + .expect("DumpCompletionCommand default name is valid"); + command.inner.set_description(Self::DEFAULT_DESCRIPTION); + command + .configure() + .expect("DumpCompletionCommand::configure uses static, valid metadata"); + command + } + + pub fn complete_impl( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + if input.must_suggest_argument_values_for("shell") { + suggestions.suggest_values( + self.get_supported_shells()? + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + Ok(()) + } + + fn guess_shell() -> String { + shirabe_php_shim::basename( + &shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .get("SHELL") + .unwrap_or_default() + .to_string_lossy(), + ) + } + + /// The PHP closure captures `$output` by reference; `Process::run` needs a `'static` + /// callback, so the shared handle is moved into it instead. + fn tail_debug_log( + &self, + command_name: &str, + output: std::rc::Rc>, + ) -> anyhow::Result<()> { + let debug_file = format!( + "{}/sf_{}.log", + shirabe_php_shim::sys_get_temp_dir(), + command_name + ); + if !shirabe_php_shim::file_exists(&debug_file) { + shirabe_php_shim::touch(&debug_file); + } + // new Process(['tail', '-f', $debugFile], null, null, null, 0) — timeout 0 disables it; + // like PHP, this tails forever until the user interrupts. + let mut process = Process::new( + vec!["tail".to_string(), "-f".to_string(), debug_file], + None, + None, + PhpMixed::Null, + Some(0.0), + )?; + process.run( + Some(Box::new(move |_type: &str, line: &str| { + output.borrow_mut().write( + &[line.to_string()], + false, + output_interface::OUTPUT_NORMAL, + ); + false + })), + indexmap::IndexMap::new(), + )?; + Ok(()) + } + + fn get_supported_shells(&self) -> anyhow::Result> { + // Deviation from PHP: the PHP implementation scans __DIR__.'/../Resources/' with a + // DirectoryIterator at runtime; the resources are embedded at compile time in this + // port, so the supported shells are a static list. + Ok(vec!["bash".to_string()]) + } +} + +impl Command for DumpCompletionCommand { + fn configure(&self) -> anyhow::Result<()> { + let full_command = shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .php_self() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let command_name = shirabe_php_shim::basename(&full_command); + // @realpath($fullCommand) ?: $fullCommand + let full_command = match shirabe_php_shim::realpath(&full_command) { + Some(p) if !p.is_empty() => p, + _ => full_command, + }; + + self.inner.set_help(&format!( + "The %command.name% command dumps the shell completion script required\n\ + to use shell autocompletion (currently only bash completion is supported).\n\ + \n\ + Static installation\n\ + -------------------\n\ + \n\ + Dump the script to a global completion file and restart your shell:\n\ + \n\ + \x20\x20\x20\x20%command.full_name% bash | sudo tee /etc/bash_completion.d/{command_name}\n\ + \n\ + Or dump the script to a local file and source it:\n\ + \n\ + \x20\x20\x20\x20%command.full_name% bash > completion.sh\n\ + \n\ + \x20\x20\x20\x20# source the file whenever you use the project\n\ + \x20\x20\x20\x20source completion.sh\n\ + \n\ + \x20\x20\x20\x20# or add this line at the end of your \"~/.bashrc\" file:\n\ + \x20\x20\x20\x20source /path/to/completion.sh\n\ + \n\ + Dynamic installation\n\ + --------------------\n\ + \n\ + Add this to the end of your shell configuration file (e.g. \"~/.bashrc\"):\n\ + \n\ + \x20\x20\x20\x20eval \"$({full_command} completion bash)\"", + )); + self.inner.add_argument( + "shell", + Some(InputArgument::OPTIONAL), + "The shell type (e.g. \"bash\"), the value of the \"$SHELL\" env var will be used if this is not given", + PhpMixed::Null, + )?; + self.inner.add_option( + "debug", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "Tail the completion debug log", + PhpMixed::Null, + )?; + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let command_name = shirabe_php_shim::basename( + &shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .argv() + .next() + .unwrap_or_default() + .to_string_lossy(), + ); + + if input.borrow().get_option("debug")?.to_bool() { + self.tail_debug_log(&command_name, output.clone())?; + + return Ok(0); + } + + let shell = match input.borrow().get_argument("shell")?.as_string() { + Some(s) => s.to_string(), + None => Self::guess_shell(), + }; + // __DIR__.'/../Resources/completion.'.$shell — resolved against the embedded + // resources; a shell without an embedded script is PHP's !file_exists() branch. + let completion_file = match shell.as_str() { + "bash" => Some(COMPLETION_BASH), + _ => None, + }; + let Some(completion_file) = completion_file else { + let supported_shells = self.get_supported_shells()?; + + // if ($output instanceof ConsoleOutputInterface) { $output = $output->getErrorOutput(); } + let output = { + let error_output = output + .borrow() + .as_console_output() + .map(|console_output| console_output.get_error_output()); + error_output.unwrap_or_else(|| output.clone()) + }; + if !shell.is_empty() { + output.borrow_mut().writeln( + &[format!( + "Detected shell \"{}\", which is not supported by Symfony shell completion (supported shells: \"{}\").", + shell, + supported_shells.join("\", \"") + )], + output_interface::OUTPUT_NORMAL, + ); + } else { + output.borrow_mut().writeln( + &[format!( + "Shell not detected, Symfony shell completion only supports \"{}\").", + supported_shells.join("\", \"") + )], + output_interface::OUTPUT_NORMAL, + ); + } + + return Ok(2); + }; + + let application = self.get_application().unwrap(); + let version = application.borrow().get_version(); + output.borrow_mut().write( + &[shirabe_php_shim::str_replace_arrays( + &[ + "{{ COMMAND_NAME }}".to_string(), + "{{ VERSION }}".to_string(), + ], + &[command_name, version], + completion_file, + )], + false, + output_interface::OUTPUT_NORMAL, + ); + + Ok(0) + } + + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + self.complete_impl(input, suggestions) + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/help_command.rs b/crates/shirabe-symfony-console/src/command/help_command.rs new file mode 100644 index 00000000..9472d144 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/help_command.rs @@ -0,0 +1,171 @@ +//! ref: composer/vendor/symfony/console/Command/HelpCommand.php + +use crate::command::command::{Command, CommandData, SetDefinitionArg}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor_interface::DescribableObject; +use crate::helper::descriptor_helper::DescriptorHelper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::DefinitionItem; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use std::ops::{Deref, DerefMut}; + +/// HelpCommand displays the help for a given command. +#[derive(Debug)] +pub struct HelpCommand { + inner: CommandData, + command: std::cell::RefCell>>>, +} + +impl_php_class!( + HelpCommand, + r"Symfony\Component\Console\Command\HelpCommand" +); + +impl Deref for HelpCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for HelpCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Default for HelpCommand { + fn default() -> Self { + Self::new() + } +} + +impl HelpCommand { + pub fn new() -> Self { + let command = HelpCommand { + inner: CommandData::new(None), + command: std::cell::RefCell::new(None), + }; + command + .configure() + .expect("HelpCommand::configure uses static, valid metadata"); + command + } + + pub fn set_command(&self, command: std::rc::Rc>) { + *self.command.borrow_mut() = Some(command); + } + + pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + if input.must_suggest_argument_values_for("command_name") { + let application = self.get_application().unwrap(); + let mut descriptor = ApplicationDescription::new(application, None, false); + suggestions.suggest_values( + descriptor + .get_commands() + .keys() + .cloned() + .map(StringOrSuggestion::String) + .collect(), + ); + + return; + } + + if input.must_suggest_option_values_for("format") { + let helper = DescriptorHelper::new(); + suggestions.suggest_values( + helper + .get_formats() + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + } +} + +impl Command for HelpCommand { + fn configure(&self) -> anyhow::Result<()> { + self.inner.ignore_validation_errors(); + + self.inner.set_name("help")?; + self.inner.set_definition(SetDefinitionArg::Array(vec![ + DefinitionItem::InputArgument(InputArgument::new( + "command_name".to_string(), + Some(InputArgument::OPTIONAL), + "The command name".to_string(), + PhpMixed::from("help".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "format", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED), + "The output format (txt, xml, json, or md)".to_string(), + PhpMixed::from("txt".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "raw", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To output raw command help".to_string(), + PhpMixed::Null, + )?), + ])); + self.inner.set_description("Display help for a command"); + self.inner.set_help( + "The %command.name% command displays help for a given command:\n\ + \n\ + \x20\x20%command.full_name% list\n\ + \n\ + You can also output the help in other formats by using the --format option:\n\ + \n\ + \x20\x20%command.full_name% --format=xml list\n\ + \n\ + To display the list of available commands, please use the list command.", + ); + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + if self.command.borrow().is_none() { + let application = self.get_application().unwrap(); + let command_name = input.borrow().get_argument("command_name")?.to_string(); + let found = application.borrow_mut().find(&command_name)?; + *self.command.borrow_mut() = Some(found); + } + + let mut helper = DescriptorHelper::new(); + let object = DescribableObject::Command(self.command.borrow().clone().unwrap()); + let mut options = indexmap::IndexMap::new(); + options.insert("format".to_string(), input.borrow().get_option("format")?); + options.insert("raw_text".to_string(), input.borrow().get_option("raw")?); + helper.describe2(output.clone(), object, options)?; + + *self.command.borrow_mut() = None; + + Ok(0) + } + + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + self.complete_impl(input, suggestions); + Ok(()) + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/list_command.rs b/crates/shirabe-symfony-console/src/command/list_command.rs new file mode 100644 index 00000000..fb0bb770 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/list_command.rs @@ -0,0 +1,172 @@ +//! ref: composer/vendor/symfony/console/Command/ListCommand.php + +use crate::command::command::{Command, CommandData, SetDefinitionArg}; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::{CompletionSuggestions, StringOrSuggestion}; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor_interface::DescribableObject; +use crate::helper::descriptor_helper::DescriptorHelper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::DefinitionItem; +use crate::input::input_interface::InputInterface; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use shirabe_php_shim::{PhpMixed, impl_php_class}; +use std::ops::{Deref, DerefMut}; + +/// ListCommand displays the list of all available commands for the application. +#[derive(Debug)] +pub struct ListCommand { + inner: CommandData, +} + +impl_php_class!( + ListCommand, + r"Symfony\Component\Console\Command\ListCommand" +); + +impl Deref for ListCommand { + type Target = CommandData; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for ListCommand { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl Default for ListCommand { + fn default() -> Self { + Self::new() + } +} + +impl ListCommand { + pub fn new() -> Self { + let command = ListCommand { + inner: CommandData::new(None), + }; + command + .configure() + .expect("ListCommand::configure uses static, valid metadata"); + command + } + + pub fn complete_impl(&self, input: &CompletionInput, suggestions: &mut CompletionSuggestions) { + if input.must_suggest_argument_values_for("namespace") { + let application = self.get_application().unwrap(); + let mut descriptor = ApplicationDescription::new(application, None, false); + suggestions.suggest_values( + descriptor + .get_namespaces() + .keys() + .cloned() + .map(StringOrSuggestion::String) + .collect(), + ); + + return; + } + + if input.must_suggest_option_values_for("format") { + let helper = DescriptorHelper::new(); + suggestions.suggest_values( + helper + .get_formats() + .into_iter() + .map(StringOrSuggestion::String) + .collect(), + ); + } + } +} + +impl Command for ListCommand { + fn configure(&self) -> anyhow::Result<()> { + self.inner.set_name("list")?; + self.inner.set_definition(SetDefinitionArg::Array(vec![ + DefinitionItem::InputArgument(InputArgument::new( + "namespace".to_string(), + Some(InputArgument::OPTIONAL), + "The namespace name".to_string(), + PhpMixed::Null, + )?), + DefinitionItem::InputOption(InputOption::new( + "raw", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To output raw command list".to_string(), + PhpMixed::Null, + )?), + DefinitionItem::InputOption(InputOption::new( + "format", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED), + "The output format (txt, xml, json, or md)".to_string(), + PhpMixed::from("txt".to_string()), + )?), + DefinitionItem::InputOption(InputOption::new( + "short", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "To skip describing commands' arguments".to_string(), + PhpMixed::Null, + )?), + ])); + self.inner.set_description("List commands"); + self.inner.set_help( + "The %command.name% command lists all commands:\n\ + \n\ + \x20\x20%command.full_name%\n\ + \n\ + You can also display the commands for a specific namespace:\n\ + \n\ + \x20\x20%command.full_name% test\n\ + \n\ + You can also output the information in other formats by using the --format option:\n\ + \n\ + \x20\x20%command.full_name% --format=xml\n\ + \n\ + It's also possible to get raw list of commands (useful for embedding command runner):\n\ + \n\ + \x20\x20%command.full_name% --raw", + ); + + Ok(()) + } + + fn execute( + &self, + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> anyhow::Result { + let mut helper = DescriptorHelper::new(); + let object = DescribableObject::Application(self.get_application().unwrap()); + let mut options = indexmap::IndexMap::new(); + options.insert("format".to_string(), input.borrow().get_option("format")?); + options.insert("raw_text".to_string(), input.borrow().get_option("raw")?); + options.insert( + "namespace".to_string(), + input.borrow().get_argument("namespace")?, + ); + options.insert("short".to_string(), input.borrow().get_option("short")?); + helper.describe2(output.clone(), object, options)?; + + Ok(0) + } + + fn complete( + &self, + input: &CompletionInput, + suggestions: &mut CompletionSuggestions, + ) -> anyhow::Result<()> { + self.complete_impl(input, suggestions); + Ok(()) + } + + crate::delegate_command_trait_impls_to_inner!(inner); +} diff --git a/crates/shirabe-symfony-console/src/command/signalable_command_interface.rs b/crates/shirabe-symfony-console/src/command/signalable_command_interface.rs new file mode 100644 index 00000000..8777eb5e --- /dev/null +++ b/crates/shirabe-symfony-console/src/command/signalable_command_interface.rs @@ -0,0 +1,10 @@ +//! ref: composer/vendor/symfony/console/Command/SignalableCommandInterface.php + +/// Interface for command reacting to signal. +pub trait SignalableCommandInterface { + /// Returns the list of signals to subscribe. + fn get_subscribed_signals(&self) -> Vec; + + /// The method will be called when the application is signaled. + fn handle_signal(&mut self, signal: i64); +} diff --git a/crates/shirabe-symfony-console/src/command_loader.rs b/crates/shirabe-symfony-console/src/command_loader.rs new file mode 100644 index 00000000..97b3f541 --- /dev/null +++ b/crates/shirabe-symfony-console/src/command_loader.rs @@ -0,0 +1,3 @@ +pub mod command_loader_interface; + +pub use command_loader_interface::*; diff --git a/crates/shirabe-symfony-console/src/command_loader/command_loader_interface.rs b/crates/shirabe-symfony-console/src/command_loader/command_loader_interface.rs new file mode 100644 index 00000000..22ed308d --- /dev/null +++ b/crates/shirabe-symfony-console/src/command_loader/command_loader_interface.rs @@ -0,0 +1,15 @@ +//! ref: composer/vendor/symfony/console/CommandLoader/CommandLoaderInterface.php + +use crate::command::command::Command; + +pub trait CommandLoaderInterface: std::fmt::Debug { + /// Loads a command. + /// + /// @throws CommandNotFoundException + fn get(&self, name: &str) -> std::rc::Rc>; + + /// Checks if a command exists. + fn has(&self, name: &str) -> bool; + + fn get_names(&self) -> Vec; +} diff --git a/crates/shirabe-symfony-console/src/completion.rs b/crates/shirabe-symfony-console/src/completion.rs new file mode 100644 index 00000000..cb7f1d12 --- /dev/null +++ b/crates/shirabe-symfony-console/src/completion.rs @@ -0,0 +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-symfony-console/src/completion/completion_input.rs b/crates/shirabe-symfony-console/src/completion/completion_input.rs new file mode 100644 index 00000000..b9785013 --- /dev/null +++ b/crates/shirabe-symfony-console/src/completion/completion_input.rs @@ -0,0 +1,404 @@ +//! ref: composer/vendor/symfony/console/Completion/CompletionInput.php + +use crate::input::argv_input::ArgvInput; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use shirabe_php_shim::{PhpMixed, php_regex}; + +/// An input specialized for shell completion. +/// +/// This input allows unfinished option names or values and exposes what kind of +/// completion is expected. +#[derive(Debug, Clone)] +pub struct CompletionInput { + inner: ArgvInput, + tokens: Vec, + current_index: i64, + completion_type: String, + completion_name: Option, + 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 { + let tokens = shirabe_php_shim::preg_match_all( + php_regex!("/(?<=^|\\s)(['\"]?)(.+?)(?, current_index: i64) -> anyhow::Result { + 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 + .base_bind(definition, |argv, token, parse_options| { + Ok(CompletionInput::parse_token(argv, token, parse_options)) + })?; + + 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 + && 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("-")).is_empty() + { + // check if previous option accepted a value + let previous_option = self.get_option_from_token(&previous_token); + if let Some(previous_option) = &previous_option + && 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 = None; + let argument_names: Vec = self + .inner + .inner + .definition + .get_arguments() + .keys() + .cloned() + .collect(); + for current_argument_name in argument_names { + // PHP's foreach assigns the key variable before the body runs, so on break + // $argumentName still names the first argument that has no bound value. + argument_name = Some(current_argument_name.clone()); + if !self + .inner + .inner + .arguments + .contains_key(¤t_argument_name) + { + break; + } + + let argument_value = self.inner.inner.arguments[¤t_argument_name].clone(); + self.completion_name = Some(current_argument_name.clone()); + if let PhpMixed::List(argument_value) = &argument_value { + self.completion_value = argument_value + .last() + .map(|v| v.to_string()) + .unwrap_or_default(); + } 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 { + 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 { + self.completion_name.clone() + } + + /// The value already typed by the user (or empty string). + pub fn get_completion_value(&self) -> String { + 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 { + self.inner.get_first_argument() + } + + /// PHP `CompletionInput::parseToken` (called back from `ArgvInput::base_bind`). Takes the + /// embedded ArgvInput instead of `&mut self` because the parse loop already holds the + /// exclusive borrow of it. + fn parse_token(inner: &mut ArgvInput, token: &str, parse_options: bool) -> bool { + match 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> { + 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 + } + } + + /// 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 + } +} + +/// PHP: `CompletionInput extends ArgvInput` — the inherited `InputInterface` surface, +/// forwarded to the embedded `ArgvInput`. `bind` dispatches to the specialized +/// `CompletionInput::bind` above, matching PHP's virtual dispatch. +impl crate::input::input_interface::InputInterface for CompletionInput { + fn dup( + &self, + ) -> std::rc::Rc> { + std::rc::Rc::new(std::cell::RefCell::new(self.clone())) + } + + fn get_first_argument(&self) -> Option { + self.inner.get_first_argument() + } + + fn __to_string(&self) -> String { + self.to_string() + } + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + crate::input::input_interface::InputInterface::has_parameter_option( + &self.inner, + values, + only_params, + ) + } + + fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + crate::input::input_interface::InputInterface::get_parameter_option( + &self.inner, + values, + default, + only_params, + ) + } + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + CompletionInput::bind(self, definition) + } + + fn validate(&mut self) -> anyhow::Result<()> { + crate::input::input_interface::InputInterface::validate(&mut self.inner) + } + + fn get_arguments(&self) -> indexmap::IndexMap { + crate::input::input_interface::InputInterface::get_arguments(&self.inner) + } + + fn get_argument(&self, name: &str) -> anyhow::Result { + crate::input::input_interface::InputInterface::get_argument(&self.inner, name) + } + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + crate::input::input_interface::InputInterface::set_argument(&mut self.inner, name, value) + } + + fn has_argument(&self, name: &str) -> bool { + crate::input::input_interface::InputInterface::has_argument(&self.inner, name) + } + + fn get_options(&self) -> indexmap::IndexMap { + crate::input::input_interface::InputInterface::get_options(&self.inner) + } + + fn get_option(&self, name: &str) -> anyhow::Result { + crate::input::input_interface::InputInterface::get_option(&self.inner, name) + } + + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + crate::input::input_interface::InputInterface::set_option(&mut self.inner, name, value) + } + + fn has_option(&self, name: &str) -> bool { + crate::input::input_interface::InputInterface::has_option(&self.inner, name) + } + + fn is_interactive(&self) -> bool { + crate::input::input_interface::InputInterface::is_interactive(&self.inner) + } + + fn set_interactive(&mut self, interactive: bool) { + crate::input::input_interface::InputInterface::set_interactive(&mut self.inner, interactive) + } + + fn as_streamable( + &self, + ) -> Option<&dyn crate::input::streamable_input_interface::StreamableInputInterface> { + crate::input::input_interface::InputInterface::as_streamable(&self.inner) + } + + fn as_streamable_mut( + &mut self, + ) -> Option<&mut dyn crate::input::streamable_input_interface::StreamableInputInterface> { + crate::input::input_interface::InputInterface::as_streamable_mut(&mut self.inner) + } +} + +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 += "|"; + } + + write!(f, "{}", shirabe_php_shim::rtrim(&str, None)) + } +} diff --git a/crates/shirabe-symfony-console/src/completion/completion_suggestions.rs b/crates/shirabe-symfony-console/src/completion/completion_suggestions.rs new file mode 100644 index 00000000..dd9235ae --- /dev/null +++ b/crates/shirabe-symfony-console/src/completion/completion_suggestions.rs @@ -0,0 +1,76 @@ +//! ref: composer/vendor/symfony/console/Completion/CompletionSuggestions.php + +use crate::completion::suggestion::Suggestion; +use crate::input::input_option::InputOption; + +/// PHP union type `string|Suggestion` used by `suggestValue`/`suggestValues`. +#[derive(Debug)] +pub enum StringOrSuggestion { + String(String), + Suggestion(Suggestion), +} + +/// Stores all completion suggestions for the current input. +#[derive(Debug)] +pub struct CompletionSuggestions { + value_suggestions: Vec, + option_suggestions: Vec>, +} + +impl Default for CompletionSuggestions { + fn default() -> Self { + Self::new() + } +} + +impl CompletionSuggestions { + 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) -> &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: std::rc::Rc) -> &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>) -> &mut Self { + for option in options { + self.suggest_option(option); + } + + self + } + + pub fn get_option_suggestions(&self) -> &Vec> { + &self.option_suggestions + } + + pub fn get_value_suggestions(&self) -> &Vec { + &self.value_suggestions + } +} diff --git a/crates/shirabe-symfony-console/src/completion/output.rs b/crates/shirabe-symfony-console/src/completion/output.rs new file mode 100644 index 00000000..6bb0a2e8 --- /dev/null +++ b/crates/shirabe-symfony-console/src/completion/output.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-symfony-console/src/completion/output/bash_completion_output.rs b/crates/shirabe-symfony-console/src/completion/output/bash_completion_output.rs new file mode 100644 index 00000000..3af69e7f --- /dev/null +++ b/crates/shirabe-symfony-console/src/completion/output/bash_completion_output.rs @@ -0,0 +1,25 @@ +//! ref: composer/vendor/symfony/console/Completion/Output/BashCompletionOutput.php + +use crate::completion::completion_suggestions::CompletionSuggestions; +use crate::completion::output::completion_output_interface::CompletionOutputInterface; +use crate::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 = 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-symfony-console/src/completion/output/completion_output_interface.rs b/crates/shirabe-symfony-console/src/completion/output/completion_output_interface.rs new file mode 100644 index 00000000..1965aa14 --- /dev/null +++ b/crates/shirabe-symfony-console/src/completion/output/completion_output_interface.rs @@ -0,0 +1,9 @@ +//! ref: composer/vendor/symfony/console/Completion/Output/CompletionOutputInterface.php + +use crate::completion::completion_suggestions::CompletionSuggestions; +use crate::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-symfony-console/src/completion/suggestion.rs b/crates/shirabe-symfony-console/src/completion/suggestion.rs new file mode 100644 index 00000000..a7db5c39 --- /dev/null +++ b/crates/shirabe-symfony-console/src/completion/suggestion.rs @@ -0,0 +1,23 @@ +//! ref: composer/vendor/symfony/console/Completion/Suggestion.php + +/// Represents a single suggested value. +#[derive(Debug)] +pub struct Suggestion { + value: String, +} + +impl Suggestion { + pub fn new(value: String) -> Self { + Self { value } + } + + pub fn get_value(&self) -> String { + self.value.clone() + } +} + +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-symfony-console/src/cursor.rs b/crates/shirabe-symfony-console/src/cursor.rs new file mode 100644 index 00000000..8eadbf27 --- /dev/null +++ b/crates/shirabe-symfony-console/src/cursor.rs @@ -0,0 +1,93 @@ +//! ref: composer/vendor/symfony/console/Cursor.php + +use crate::output::OutputInterface; +use crate::output::output_interface; + +#[derive(Debug)] +pub struct Cursor { + output: std::rc::Rc>, + input: shirabe_php_shim::PhpResource, +} + +impl Cursor { + pub fn new( + output: std::rc::Rc>, + input: Option, + ) -> Self { + let input = input.unwrap_or(shirabe_php_shim::STDIN); + + Self { output, input } + } + + pub fn move_up(&self, lines: i64) -> &Self { + self.output.borrow().write( + &[format!("\x1b[{}A", lines)], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn move_left(&self, columns: i64) -> &Self { + self.output.borrow().write( + &[format!("\x1b[{}D", columns)], + false, + output_interface::OUTPUT_NORMAL, + ); + + self + } + + pub fn move_to_column(&self, column: i64) -> &Self { + self.output.borrow().write( + &[format!("\x1b[{}G", 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 + } + + /// 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 + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor.rs b/crates/shirabe-symfony-console/src/descriptor.rs new file mode 100644 index 00000000..9f85f925 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor.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-symfony-console/src/descriptor/application_description.rs b/crates/shirabe-symfony-console/src/descriptor/application_description.rs new file mode 100644 index 00000000..d63f0be2 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/application_description.rs @@ -0,0 +1,189 @@ +//! ref: composer/vendor/symfony/console/Descriptor/ApplicationDescription.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::exception::command_not_found_exception::CommandNotFoundException; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// @internal +#[derive(Debug)] +pub struct ApplicationDescription { + application: std::rc::Rc>, + namespace: Option, + show_hidden: bool, + + /// @var array + /// Each namespace entry is `['id' => string, 'commands' => string[]]`. + namespaces: Option>>, + + /// @var array + commands: Option>>>, + + /// @var array + aliases: Option>>>, +} + +impl ApplicationDescription { + pub const GLOBAL_NAMESPACE: &'static str = "_global"; + + pub fn new( + application: std::rc::Rc>, + namespace: Option, + show_hidden: bool, + ) -> Self { + ApplicationDescription { + application, + namespace, + show_hidden, + namespaces: None, + commands: None, + aliases: None, + } + } + + pub fn get_namespaces(&mut self) -> IndexMap> { + if self.namespaces.is_none() { + self.inspect_application(); + } + + self.namespaces.clone().unwrap() + } + + pub fn get_commands( + &mut self, + ) -> &IndexMap>> { + if self.commands.is_none() { + self.inspect_application(); + } + + self.commands.as_ref().unwrap() + } + + /// @throws CommandNotFoundException + pub fn get_command( + &self, + name: &str, + ) -> anyhow::Result>> { + 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 = 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 = IndexMap::new(); + entry.insert("id".to_string(), PhpMixed::String(namespace.clone())); + entry.insert( + "commands".to_string(), + PhpMixed::List(names.into_iter().map(PhpMixed::String).collect()), + ); + self.namespaces.as_mut().unwrap().insert(namespace, entry); + } + } + + fn sort_commands( + &self, + commands: IndexMap>>, + ) -> IndexMap>>> { + let mut namespaced_commands: IndexMap< + String, + IndexMap>>, + > = IndexMap::new(); + let mut global_commands: IndexMap>> = + IndexMap::new(); + let mut sorted_commands: IndexMap< + String, + IndexMap>>, + > = IndexMap::new(); + for (name, command) in commands { + let key = self.application.borrow().extract_namespace(&name, Some(1)); + if ["", Self::GLOBAL_NAMESPACE].contains(&key.as_str()) { + global_commands.insert(name, command); + } else { + namespaced_commands + .entry(key) + .or_default() + .insert(name, command); + } + } + + if !global_commands.is_empty() { + global_commands.sort_keys(); + sorted_commands.insert(Self::GLOBAL_NAMESPACE.to_string(), global_commands); + } + + if !namespaced_commands.is_empty() { + // ksort($namespacedCommands, \SORT_STRING) + namespaced_commands.sort_keys(); + for (key, mut commands_set) in namespaced_commands { + commands_set.sort_keys(); + sorted_commands.insert(key, commands_set); + } + } + + sorted_commands + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/descriptor.rs new file mode 100644 index 00000000..f3647d7c --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/descriptor.rs @@ -0,0 +1,97 @@ +//! ref: composer/vendor/symfony/console/Descriptor/Descriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// @internal +pub trait Descriptor: DescriptorInterface { + fn output(&self) -> std::rc::Rc>; + + fn set_output(&mut self, output: std::rc::Rc>); + + fn describe( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> anyhow::Result<()> { + self.set_output(output); + + // PHP dispatches via `$object instanceof ...`; the explicit `DescribableObject` enum makes + // that dispatch a `match`. + match object { + DescribableObject::InputArgument(argument) => { + self.describe_input_argument(&argument, options)?; + } + DescribableObject::InputOption(option) => { + self.describe_input_option(&option, options)?; + } + DescribableObject::InputDefinition(definition) => { + self.describe_input_definition(&definition, options)?; + } + DescribableObject::Command(command) => { + self.describe_command(&*command.borrow(), options)?; + } + DescribableObject::Application(application) => { + self.describe_application(application, options)?; + } + } + + Ok(()) + } + + /// Writes content to output. + fn write(&self, content: &str, decorated: bool) { + self.output().borrow().write( + &[content.to_string()], + false, + if decorated { + crate::output::output_interface::OUTPUT_NORMAL + } else { + crate::output::output_interface::OUTPUT_RAW + }, + ); + } + + /// Describes an InputArgument instance. + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap, + ) -> anyhow::Result<()>; + + /// Describes an InputOption instance. + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap, + ) -> anyhow::Result<()>; + + /// Describes an InputDefinition instance. + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap, + ) -> anyhow::Result<()>; + + /// Describes a Command instance. + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> anyhow::Result<()>; + + /// Describes an Application instance. + fn describe_application( + &mut self, + application: std::rc::Rc>, + options: IndexMap, + ) -> anyhow::Result<()>; +} diff --git a/crates/shirabe-symfony-console/src/descriptor/descriptor_interface.rs b/crates/shirabe-symfony-console/src/descriptor/descriptor_interface.rs new file mode 100644 index 00000000..c50c6a99 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/descriptor_interface.rs @@ -0,0 +1,29 @@ +//! ref: composer/vendor/symfony/console/Descriptor/DescriptorInterface.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// The set of objects the descriptors know how to describe. +pub enum DescribableObject { + InputArgument(InputArgument), + InputOption(InputOption), + InputDefinition(InputDefinition), + Command(std::rc::Rc>), + Application(std::rc::Rc>), +} + +/// Descriptor interface. +pub trait DescriptorInterface { + fn describe( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> anyhow::Result<()>; +} diff --git a/crates/shirabe-symfony-console/src/descriptor/json_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/json_descriptor.rs new file mode 100644 index 00000000..983d5f08 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/json_descriptor.rs @@ -0,0 +1,402 @@ +//! ref: composer/vendor/symfony/console/Descriptor/JsonDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_pcre::preg::Preg; +use shirabe_php_shim::{PhpMixed, php_regex}; + +/// JSON descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct JsonDescriptor { + output: Option>>, +} + +impl JsonDescriptor { + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap, + ) -> anyhow::Result<()> { + self.write_data(self.get_input_argument_data(argument)?, &options)?; + Ok(()) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap, + ) -> 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, + ) -> anyhow::Result<()> { + self.write_data(self.get_input_definition_data(definition)?, &options)?; + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> 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>, + options: IndexMap, + ) -> 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 = vec![]; + + let short = matches!(options.get("short"), Some(PhpMixed::Bool(true))); + for command in description.get_commands().values() { + let command = command.borrow(); + commands.push(PhpMixed::Array( + self.get_command_data(&*command, short)? + .into_iter() + .collect(), + )); + } + + let mut data: IndexMap = IndexMap::new(); + if "UNKNOWN" != application.borrow().get_name() { + let mut application_data: IndexMap = IndexMap::new(); + application_data.insert( + "name".to_string(), + PhpMixed::String(application.borrow().get_name()), + ); + if "UNKNOWN" != application.borrow().get_version() { + application_data.insert( + "version".to_string(), + PhpMixed::String(application.borrow().get_version()), + ); + } + data.insert("application".to_string(), PhpMixed::Array(application_data)); + } + + data.insert("commands".to_string(), PhpMixed::List(commands)); + + if let Some(described_namespace) = described_namespace { + data.insert( + "namespace".to_string(), + PhpMixed::String(described_namespace), + ); + } else { + data.insert( + "namespaces".to_string(), + PhpMixed::List( + description + .get_namespaces() + .into_values() + .map(|ns| PhpMixed::Array(ns.into_iter().collect())) + .collect(), + ), + ); + } + + self.write_data(data, &options)?; + Ok(()) + } + + /// Writes data as json. + fn write_data( + &self, + data: IndexMap, + options: &IndexMap, + ) -> anyhow::Result<()> { + let flags = match options.get("json_encoding") { + Some(PhpMixed::Int(f)) => *f, + _ => 0, + }; + + self.write( + &shirabe_php_shim::json_encode_ex(&PhpMixed::Array(data.into_iter().collect()), flags) + .unwrap_or_default(), + false, + ); + Ok(()) + } + + fn get_input_argument_data( + &self, + argument: &InputArgument, + ) -> anyhow::Result> { + let mut data: IndexMap = IndexMap::new(); + data.insert( + "name".to_string(), + PhpMixed::String(argument.get_name().to_string()), + ); + data.insert( + "is_required".to_string(), + PhpMixed::Bool(argument.is_required()), + ); + data.insert("is_array".to_string(), PhpMixed::Bool(argument.is_array())); + data.insert( + "description".to_string(), + PhpMixed::String(Preg::replace( + php_regex!("/\\s*[\\r\\n]\\s*/"), + " ", + argument.get_description(), + )), + ); + data.insert( + "default".to_string(), + if matches!(argument.get_default(), PhpMixed::Float(f) if f.is_infinite() && *f > 0.0) { + PhpMixed::String("INF".to_string()) + } else { + argument.get_default().clone() + }, + ); + Ok(data) + } + + fn get_input_option_data( + &self, + option: &InputOption, + negated: bool, + ) -> anyhow::Result> { + let mut data: IndexMap = IndexMap::new(); + if negated { + data.insert( + "name".to_string(), + PhpMixed::String(format!("--no-{}", option.get_name())), + ); + data.insert("shortcut".to_string(), PhpMixed::String(String::new())); + data.insert("accept_value".to_string(), PhpMixed::Bool(false)); + data.insert("is_value_required".to_string(), PhpMixed::Bool(false)); + data.insert("is_multiple".to_string(), PhpMixed::Bool(false)); + data.insert( + "description".to_string(), + PhpMixed::String(format!("Negate the \"--{}\" option", option.get_name())), + ); + data.insert("default".to_string(), PhpMixed::Bool(false)); + } else { + data.insert( + "name".to_string(), + PhpMixed::String(format!("--{}", option.get_name())), + ); + data.insert( + "shortcut".to_string(), + PhpMixed::String(if let Some(shortcut) = option.get_shortcut() { + format!("-{}", shirabe_php_shim::str_replace("|", "|-", shortcut)) + } else { + String::new() + }), + ); + data.insert( + "accept_value".to_string(), + PhpMixed::Bool(option.accept_value()), + ); + data.insert( + "is_value_required".to_string(), + PhpMixed::Bool(option.is_value_required()), + ); + data.insert("is_multiple".to_string(), PhpMixed::Bool(option.is_array())); + data.insert( + "description".to_string(), + PhpMixed::String(Preg::replace( + php_regex!("/\\s*[\\r\\n]\\s*/"), + " ", + option.get_description(), + )), + ); + data.insert( + "default".to_string(), + if matches!(option.get_default(), PhpMixed::Float(f) if f.is_infinite() && *f > 0.0) + { + PhpMixed::String("INF".to_string()) + } else { + option.get_default().clone() + }, + ); + } + Ok(data) + } + + fn get_input_definition_data( + &self, + definition: &InputDefinition, + ) -> anyhow::Result> { + let mut input_arguments: IndexMap = IndexMap::new(); + for (name, argument) in definition.get_arguments() { + input_arguments.insert( + name.clone(), + PhpMixed::Array( + self.get_input_argument_data(argument)? + .into_iter() + .collect(), + ), + ); + } + + let mut input_options: IndexMap = IndexMap::new(); + for (name, option) in definition.get_options() { + input_options.insert( + name.clone(), + PhpMixed::Array( + self.get_input_option_data(option, false)? + .into_iter() + .collect(), + ), + ); + if option.is_negatable() { + input_options.insert( + format!("no-{}", name), + PhpMixed::Array( + self.get_input_option_data(option, true)? + .into_iter() + .collect(), + ), + ); + } + } + + let mut data: IndexMap = IndexMap::new(); + data.insert("arguments".to_string(), PhpMixed::Array(input_arguments)); + data.insert("options".to_string(), PhpMixed::Array(input_options)); + Ok(data) + } + + fn get_command_data( + &self, + command: &dyn Command, + short: bool, + ) -> anyhow::Result> { + let mut data: IndexMap = IndexMap::new(); + data.insert( + "name".to_string(), + match command.get_name() { + Some(name) => PhpMixed::String(name), + None => PhpMixed::Null, + }, + ); + data.insert( + "description".to_string(), + PhpMixed::String(command.get_description()), + ); + + if short { + data.insert( + "usage".to_string(), + PhpMixed::List( + command + .get_aliases() + .into_iter() + .map(PhpMixed::String) + .collect(), + ), + ); + } else { + command.merge_application_definition(false); + + let mut usage = vec![PhpMixed::String(command.get_synopsis(false))]; + usage.extend(command.get_usages().into_iter().map(PhpMixed::String)); + usage.extend(command.get_aliases().into_iter().map(PhpMixed::String)); + data.insert("usage".to_string(), PhpMixed::List(usage)); + data.insert( + "help".to_string(), + PhpMixed::String(command.get_processed_help()), + ); + data.insert( + "definition".to_string(), + PhpMixed::Array( + self.get_input_definition_data(&command.get_definition())? + .into_iter() + .collect(), + ), + ); + } + + data.insert("hidden".to_string(), PhpMixed::Bool(command.is_hidden())); + + Ok(data) + } +} + +impl DescriptorInterface for JsonDescriptor { + fn describe( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> anyhow::Result<()> { + Descriptor::describe(self, output, object, options) + } +} + +impl Descriptor for JsonDescriptor { + fn output(&self) -> std::rc::Rc> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc>) { + self.output = Some(output); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc>, + options: IndexMap, + ) -> anyhow::Result<()> { + JsonDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/markdown_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/markdown_descriptor.rs new file mode 100644 index 00000000..b64c2be0 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/markdown_descriptor.rs @@ -0,0 +1,378 @@ +//! ref: composer/vendor/symfony/console/Descriptor/MarkdownDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::helper::helper::Helper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_pcre::preg::Preg; +use shirabe_php_shim::PhpMixed; + +/// Markdown descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct MarkdownDescriptor { + output: Option>>, +} + +impl MarkdownDescriptor { + pub fn describe( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> 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, + ) -> anyhow::Result<()> { + let name = if !argument.get_name().is_empty() { + argument.get_name().to_string() + } else { + "".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, + ) -> 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, + ) -> anyhow::Result<()> { + let show_arguments = !definition.get_arguments().is_empty(); + if show_arguments { + self.write("### Arguments", true); + for argument in definition.get_arguments().values() { + self.write("\n\n", true); + // describeInputArgument returns null; the guarded write never runs. + self.describe_input_argument(argument, IndexMap::new())?; + } + } + + if !definition.get_options().is_empty() { + if show_arguments { + self.write("\n\n", true); + } + + self.write("### Options", true); + for option in definition.get_options().values() { + self.write("\n\n", true); + // describeInputOption returns null; the guarded write never runs. + self.describe_input_option(option, IndexMap::new())?; + } + } + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> 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>, + options: IndexMap, + ) -> 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 = match namespace.get("commands") { + Some(PhpMixed::List(names)) => names + .iter() + .filter_map(|n| n.as_string().map(|s| s.to_string())) + .collect(), + _ => vec![], + }; + self.write( + &command_names + .iter() + .map(|command_name| { + Ok(format!( + "* [`{}`](#{})", + command_name.clone(), + shirabe_php_shim::str_replace( + ":", + "", + &description + .get_command(command_name)? + .borrow() + .get_name() + .unwrap_or_default(), + ), + )) + }) + .collect::>>()? + .join("\n"), + true, + ); + } + + for command in description.get_commands().values() { + let command = command.borrow(); + self.write("\n\n", true); + // describeCommand returns null; the guarded write never runs. + self.describe_command(&*command, options.clone())?; + } + Ok(()) + } + + fn get_application_title(&self, application: &dyn Application) -> String { + if "UNKNOWN" != application.get_name() { + if "UNKNOWN" != application.get_version() { + return format!("{} {}", application.get_name(), application.get_version()); + } + + return application.get_name(); + } + + "Console Tool".to_string() + } +} + +impl DescriptorInterface for MarkdownDescriptor { + fn describe( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe(self, output, object, options) + } +} + +impl Descriptor for MarkdownDescriptor { + fn output(&self) -> std::rc::Rc> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc>) { + self.output = Some(output); + } + + /// {@inheritdoc} + fn write(&self, content: &str, decorated: bool) { + // PHP overrides write() only to flip the default of $decorated to true; + // it still delegates to parent::write. + let _ = decorated; + self.output().borrow().write( + &[content.to_string()], + false, + if decorated { + crate::output::output_interface::OUTPUT_NORMAL + } else { + crate::output::output_interface::OUTPUT_RAW + }, + ); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc>, + options: IndexMap, + ) -> anyhow::Result<()> { + MarkdownDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/text_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/text_descriptor.rs new file mode 100644 index 00000000..3ea09355 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/text_descriptor.rs @@ -0,0 +1,592 @@ +//! ref: composer/vendor/symfony/console/Descriptor/TextDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::formatter::output_formatter::OutputFormatter; +use crate::helper::helper::Helper; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_pcre::preg::Preg; +use shirabe_php_shim::PhpMixed; + +/// Text descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct TextDescriptor { + output: Option>>, +} + +/// Models PHP's `array` passed to `getColumnWidth`. +/// `PhpMixed` cannot hold console types, so a dedicated enum is used. +#[derive(Debug)] +enum CommandOrString { + Command(std::rc::Rc>), + String(String), +} + +impl TextDescriptor { + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap, + ) -> 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!( + " [default: {}]", + self.format_default_value(argument.get_default())? + ) + } else { + String::new() + }; + + let total_width = match options.get("total_width") { + Some(PhpMixed::Int(w)) => *w, + _ => Helper::width(argument.get_name()), + }; + let spacing_width = total_width - shirabe_php_shim::strlen(argument.get_name()); + + self.write_text( + &format!( + " {} {}{}{}", + argument.get_name(), + shirabe_php_shim::str_repeat(" ", spacing_width as usize), + // + 4 = 2 spaces before , 2 spaces after + Preg::replace( + "/\\s*[\\r\\n]\\s*/", + &format!( + "\n{}", + shirabe_php_shim::str_repeat(" ", (total_width + 4) as usize) + ), + argument.get_description(), + ), + default, + ), + &options, + ); + Ok(()) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap, + ) -> 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!( + " [default: {}]", + self.format_default_value(option.get_default())? + ) + } else { + String::new() + }; + + let mut value = String::new(); + if option.accept_value() { + value = format!("={}", shirabe_php_shim::strtoupper(option.get_name())); + + if option.is_value_optional() { + value = format!("[{}]", value); + } + } + + let total_width = match options.get("total_width") { + Some(PhpMixed::Int(w)) => *w, + _ => self.calculate_total_width_for_options(&[option]), + }; + let synopsis = format!( + "{}{}", + if option.get_shortcut().is_some() { + format!("-{}, ", option.get_shortcut().unwrap()) + } else { + " ".to_string() + }, + if option.is_negatable() { + format!("--{0}|--no-{0}", option.get_name().to_string()) + } else { + format!("--{0}{1}", option.get_name(), value) + } + ); + + let spacing_width = total_width - Helper::width(&synopsis); + + self.write_text( + &format!( + " {} {}{}{}{}", + synopsis, + shirabe_php_shim::str_repeat(" ", spacing_width as usize), + // + 4 = 2 spaces before , 2 spaces after + Preg::replace( + "/\\s*[\\r\\n]\\s*/", + &format!( + "\n{}", + shirabe_php_shim::str_repeat(" ", (total_width + 4) as usize) + ), + option.get_description(), + ), + default, + if option.is_array() { + " (multiple values allowed)".to_string() + } else { + String::new() + }, + ), + &options, + ); + Ok(()) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap, + ) -> anyhow::Result<()> { + let mut total_width = self.calculate_total_width_for_options( + &definition + .get_options() + .values() + .map(|o| o.as_ref()) + .collect::>(), + ); + 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("Arguments:", &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("Options:", &options); + for option in definition.get_options().values() { + if shirabe_php_shim::strlen(option.get_shortcut().unwrap_or("")) > 1 { + later_options.push(option.as_ref()); + continue; + } + self.write_text("\n", &IndexMap::new()); + let mut merged = options.clone(); + merged.insert("total_width".to_string(), PhpMixed::Int(total_width)); + self.describe_input_option(option, merged)?; + } + for option in later_options { + self.write_text("\n", &IndexMap::new()); + let mut merged = options.clone(); + merged.insert("total_width".to_string(), PhpMixed::Int(total_width)); + self.describe_input_option(option, merged)?; + } + } + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> anyhow::Result<()> { + command.merge_application_definition(false); + + let description = command.get_description(); + if !description.is_empty() { + self.write_text("Description:", &options); + self.write_text("\n", &IndexMap::new()); + self.write_text(&format!(" {}", description), &IndexMap::new()); + self.write_text("\n\n", &IndexMap::new()); + } + + self.write_text("Usage:", &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("Help:", &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>, + options: IndexMap, + ) -> 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::>(), + ); + + let command_list: Vec<_> = description.get_commands().values().cloned().collect(); + for command in &command_list { + let command = command.borrow(); + self.write_text( + &format!( + "{:Usage:\n", &options); + self.write_text(" command [options] [arguments]\n\n", &options); + + let app_definition = application.borrow_mut().get_definition(); + let options_only: Vec> = app_definition + .borrow() + .get_options() + .values() + .cloned() + .collect(); + let definition = InputDefinition::from_options(options_only)?; + self.describe_input_definition(&definition, options.clone())?; + + self.write_text("\n", &IndexMap::new()); + self.write_text("\n", &IndexMap::new()); + + let mut commands = description.get_commands().clone(); + let namespaces = description.get_namespaces(); + if described_namespace.is_some() && !namespaces.is_empty() { + // make sure all alias commands are included when describing a specific namespace + let described_namespace_info = namespaces.values().next().unwrap(); + if let Some(PhpMixed::List(names)) = described_namespace_info.get("commands") { + let names: Vec = 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 = commands.keys().cloned().collect(); + let mut merged: Vec = vec![]; + for namespace in namespaces.values() { + if let Some(PhpMixed::List(ns_commands)) = namespace.get("commands") { + for c in ns_commands { + if let PhpMixed::String(name) = c + && command_keys.contains(name) + { + merged.push(CommandOrString::String(name.clone())); + } + } + } + } + merged + }); + + if let Some(ref described_namespace) = described_namespace { + self.write_text( + &format!( + "Available commands for the \"{}\" namespace:", + described_namespace.clone(), + ), + &options, + ); + } else { + self.write_text("Available commands:", &options); + } + + for namespace in namespaces.values() { + let ns_commands: Vec = match namespace.get("commands") { + Some(PhpMixed::List(names)) => names + .iter() + .filter_map(|n| match n { + PhpMixed::String(name) if commands.contains_key(name) => { + Some(name.clone()) + } + _ => None, + }) + .collect(), + _ => vec![], + }; + + if ns_commands.is_empty() { + continue; + } + + let namespace_id = match namespace.get("id") { + Some(PhpMixed::String(s)) => s.clone(), + _ => String::new(), + }; + + if described_namespace.is_none() + && ApplicationDescription::GLOBAL_NAMESPACE != namespace_id + { + self.write_text("\n", &IndexMap::new()); + self.write_text(&format!(" {}", namespace_id), &options); + } + + for name in ns_commands { + self.write_text("\n", &IndexMap::new()); + let spacing_width = width - Helper::width(&name); + let command = commands.get(&name).unwrap().clone(); + let command = command.borrow(); + let command_aliases = if command.get_name().as_deref() == Some(name.as_str()) { + self.get_command_aliases_text(&*command) + } else { + String::new() + }; + self.write_text( + &format!( + " {}{}{}{}", + name.clone(), + shirabe_php_shim::str_repeat(" ", spacing_width as usize), + command_aliases, + command.get_description(), + ), + &options, + ); + } + } + + self.write_text("\n", &IndexMap::new()); + } + Ok(()) + } + + fn write_text(&self, content: &str, options: &IndexMap) { + 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 { + if matches!(default, PhpMixed::Float(f) if f.is_infinite() && *f > 0.0) { + return Ok("INF".to_string()); + } + + let default = match default { + PhpMixed::String(s) => PhpMixed::String(OutputFormatter::escape(s)?), + PhpMixed::Array(arr) => { + let mut arr = arr.clone(); + for (_key, value) in arr.iter_mut() { + if let PhpMixed::String(s) = &*value { + *value = PhpMixed::String(OutputFormatter::escape(s)?); + } + } + PhpMixed::Array(arr) + } + PhpMixed::List(list) => { + let mut list = list.clone(); + for value in list.iter_mut() { + if let PhpMixed::String(s) = &*value { + *value = PhpMixed::String(OutputFormatter::escape(s)?); + } + } + PhpMixed::List(list) + } + other => other.clone(), + }; + + Ok(shirabe_php_shim::str_replace( + "\\\\", + "\\", + &shirabe_php_shim::json_encode_ex( + &default, + shirabe_php_shim::JSON_UNESCAPED_SLASHES | shirabe_php_shim::JSON_UNESCAPED_UNICODE, + ) + .unwrap_or_default(), + )) + } + + fn get_column_width(&self, commands: &[CommandOrString]) -> i64 { + let mut widths: Vec = vec![]; + + for command in commands { + // case $command instanceof Command + match command { + CommandOrString::Command(command) => { + let command = command.borrow(); + widths.push(Helper::width(command.get_name().as_deref().unwrap_or(""))); + for alias in command.get_aliases() { + widths.push(Helper::width(&alias)); + } + } + CommandOrString::String(s) => { + widths.push(Helper::width(s)); + } + } + } + + if !widths.is_empty() { + widths.into_iter().max().unwrap() + 2 + } else { + 0 + } + } + + fn calculate_total_width_for_options(&self, options: &[&InputOption]) -> i64 { + let mut total_width: i64 = 0; + for option in options { + // "-" + shortcut + ", --" + name + let mut name_length = 1 + + Helper::width(option.get_shortcut().unwrap_or("")).max(1) + + 4 + + Helper::width(option.get_name()); + if option.is_negatable() { + name_length += 6 + Helper::width(option.get_name()); // |--no- + name + } else if option.accept_value() { + let mut value_length = 1 + Helper::width(option.get_name()); // = + value + value_length += if option.is_value_optional() { 2 } else { 0 }; // [ + ] + + name_length += value_length; + } + total_width = std::cmp::max(total_width, name_length); + } + + total_width + } +} + +impl DescriptorInterface for TextDescriptor { + fn describe( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> anyhow::Result<()> { + Descriptor::describe(self, output, object, options) + } +} + +impl Descriptor for TextDescriptor { + fn output(&self) -> std::rc::Rc> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc>) { + self.output = Some(output); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap, + ) -> anyhow::Result<()> { + TextDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap, + ) -> anyhow::Result<()> { + TextDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap, + ) -> anyhow::Result<()> { + TextDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> anyhow::Result<()> { + TextDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc>, + options: IndexMap, + ) -> anyhow::Result<()> { + TextDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-symfony-console/src/descriptor/xml_descriptor.rs b/crates/shirabe-symfony-console/src/descriptor/xml_descriptor.rs new file mode 100644 index 00000000..11c26b46 --- /dev/null +++ b/crates/shirabe-symfony-console/src/descriptor/xml_descriptor.rs @@ -0,0 +1,403 @@ +//! ref: composer/vendor/symfony/console/Descriptor/XmlDescriptor.php + +use crate::application::Application; +use crate::command::command::Command; +use crate::descriptor::application_description::ApplicationDescription; +use crate::descriptor::descriptor::Descriptor; +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::input::input_argument::InputArgument; +use crate::input::input_definition::InputDefinition; +use crate::input::input_option::InputOption; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::{DOMDocument, DOMNode, PhpMixed}; + +/// XML descriptor. +/// +/// @internal +#[derive(Debug, Default)] +pub struct XmlDescriptor { + output: Option>>, +} + +impl XmlDescriptor { + pub fn get_input_definition_document(&self, definition: &InputDefinition) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + let definition_xml = dom.append_child(dom.create_element("definition")); + + let arguments_xml = definition_xml.append_child(dom.create_element("arguments")); + for argument in definition.get_arguments().values() { + let argument_xml = self.get_input_argument_document(argument); + self.append_document(&arguments_xml, &argument_xml.as_node()); + } + + let options_xml = definition_xml.append_child(dom.create_element("options")); + for option in definition.get_options().values() { + let option_xml = self.get_input_option_document(option); + self.append_document(&options_xml, &option_xml.as_node()); + } + + dom + } + + pub fn get_command_document(&self, command: &dyn Command, short: bool) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + let command_xml = dom.append_child(dom.create_element("command")); + + let name = command.get_name().unwrap_or_default(); + command_xml.set_attribute("id", &name); + command_xml.set_attribute("name", &name); + command_xml.set_attribute("hidden", if command.is_hidden() { "1" } else { "0" }); + + let usages_xml = command_xml.append_child(dom.create_element("usages")); + + let description_xml = command_xml.append_child(dom.create_element("description")); + description_xml.append_child(dom.create_text_node(&shirabe_php_shim::str_replace( + "\n", + "\n ", + &command.get_description(), + ))); + + if short { + for usage in command.get_aliases() { + usages_xml.append_child(dom.create_element_with_value("usage", &usage)); + } + } else { + command.merge_application_definition(false); + + let mut usages = vec![command.get_synopsis(false)]; + usages.extend(command.get_aliases()); + usages.extend(command.get_usages()); + for usage in usages { + usages_xml.append_child(dom.create_element_with_value("usage", &usage)); + } + + let help_xml = command_xml.append_child(dom.create_element("help")); + help_xml.append_child(dom.create_text_node(&shirabe_php_shim::str_replace( + "\n", + "\n ", + &command.get_processed_help(), + ))); + + let command_definition = command.get_definition().clone(); + let definition_xml = self.get_input_definition_document(&command_definition); + let definition_node = definition_xml + .get_elements_by_tag_name("definition") + .item(0) + .expect("input definition document always contains a element"); + self.append_document(&command_xml, &definition_node); + } + + dom + } + + pub fn get_application_document( + &self, + application: std::rc::Rc>, + namespace: Option, + short: bool, + ) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + let root_xml = dom.append_child(dom.create_element("symfony")); + + let app_name = application.borrow().get_name(); + if app_name != "UNKNOWN" { + root_xml.set_attribute("name", &app_name); + let app_version = application.borrow().get_version(); + if app_version != "UNKNOWN" { + root_xml.set_attribute("version", &app_version); + } + } + + let commands_xml = root_xml.append_child(dom.create_element("commands")); + + let mut description = + ApplicationDescription::new(application.clone(), namespace.clone(), true); + + if let Some(ref namespace) = namespace { + commands_xml.set_attribute("namespace", namespace); + } + + for command in description.get_commands().values() { + let command = command.borrow(); + let command_xml = self.get_command_document(&*command, short); + self.append_document(&commands_xml, &command_xml.as_node()); + } + + if namespace.is_none() { + let namespaces_xml = root_xml.append_child(dom.create_element("namespaces")); + + let namespaces = description.get_namespaces(); + for namespace_description in namespaces.values() { + let namespace_array_xml = + namespaces_xml.append_child(dom.create_element("namespace")); + let id = match namespace_description.get("id") { + Some(PhpMixed::String(s)) => s.as_str(), + _ => "", + }; + namespace_array_xml.set_attribute("id", id); + + if let Some(PhpMixed::List(names)) = namespace_description.get("commands") { + for name in names { + let command_xml = + namespace_array_xml.append_child(dom.create_element("command")); + command_xml + .append_child(dom.create_text_node(name.as_string().unwrap_or(""))); + } + } + } + } + + dom + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + _options: IndexMap, + ) -> anyhow::Result<()> { + self.write_document(self.get_input_argument_document(argument)); + Ok(()) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + _options: IndexMap, + ) -> anyhow::Result<()> { + self.write_document(self.get_input_option_document(option)); + Ok(()) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + _options: IndexMap, + ) -> anyhow::Result<()> { + self.write_document(self.get_input_definition_document(definition)); + Ok(()) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> 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>, + options: IndexMap, + ) -> anyhow::Result<()> { + let namespace = match options.get("namespace") { + Some(PhpMixed::String(s)) => Some(s.clone()), + _ => None, + }; + let short = matches!(options.get("short"), Some(PhpMixed::Bool(true))); + self.write_document(self.get_application_document(application, namespace, short)); + Ok(()) + } + + /// Appends document children to parent node. + fn append_document(&self, parent_node: &DOMNode, imported_parent: &DOMNode) { + for child_node in imported_parent.child_nodes() { + parent_node.append_child(parent_node.owner_document().import_node(&child_node, true)); + } + } + + /// Writes DOM document. + fn write_document(&self, dom: DOMDocument) { + dom.set_format_output(true); + let mut buf = Vec::new(); + dom.save_xml(&mut buf) + .expect("serializing XML to an in-memory buffer cannot fail"); + let xml = String::from_utf8(buf).expect("DOM serialization yields valid UTF-8"); + self.write(&xml, false); + } + + fn get_input_argument_document(&self, argument: &InputArgument) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + + let object_xml = dom.append_child(dom.create_element("argument")); + object_xml.set_attribute("name", argument.get_name()); + object_xml.set_attribute( + "is_required", + if argument.is_required() { "1" } else { "0" }, + ); + object_xml.set_attribute("is_array", if argument.is_array() { "1" } else { "0" }); + let description_xml = object_xml.append_child(dom.create_element("description")); + description_xml.append_child(dom.create_text_node(argument.get_description())); + + let defaults_xml = object_xml.append_child(dom.create_element("defaults")); + let defaults: Vec = match argument.get_default() { + PhpMixed::List(_) | PhpMixed::Array(_) => { + self.default_values_as_strings(argument.get_default()) + } + PhpMixed::Bool(_) => vec![shirabe_php_shim::var_export(argument.get_default(), true)], + d if shirabe_php_shim::php_truthy(d) => { + vec![shirabe_php_shim::php_to_string(argument.get_default())] + } + _ => vec![], + }; + for default in defaults { + let default_xml = defaults_xml.append_child(dom.create_element("default")); + default_xml.append_child(dom.create_text_node(&default)); + } + + dom + } + + fn get_input_option_document(&self, option: &InputOption) -> DOMDocument { + let dom = DOMDocument::new("1.0", "UTF-8"); + + let object_xml = dom.append_child(dom.create_element("option")); + object_xml.set_attribute("name", &format!("--{}", option.get_name())); + let pos = shirabe_php_shim::strpos(option.get_shortcut().unwrap_or(""), "|"); + if let Some(pos) = pos { + object_xml.set_attribute( + "shortcut", + &format!( + "-{}", + shirabe_php_shim::substr(option.get_shortcut().unwrap(), 0, Some(pos as i64)) + ), + ); + object_xml.set_attribute( + "shortcuts", + &format!( + "-{}", + shirabe_php_shim::str_replace("|", "|-", option.get_shortcut().unwrap()) + ), + ); + } else { + object_xml.set_attribute( + "shortcut", + &match option.get_shortcut() { + Some(s) => format!("-{}", s), + None => String::new(), + }, + ); + } + object_xml.set_attribute( + "accept_value", + if option.accept_value() { "1" } else { "0" }, + ); + object_xml.set_attribute( + "is_value_required", + if option.is_value_required() { "1" } else { "0" }, + ); + object_xml.set_attribute("is_multiple", if option.is_array() { "1" } else { "0" }); + let description_xml = object_xml.append_child(dom.create_element("description")); + description_xml.append_child(dom.create_text_node(option.get_description())); + + if option.accept_value() { + let defaults: Vec = match option.get_default() { + PhpMixed::List(_) | PhpMixed::Array(_) => { + self.default_values_as_strings(option.get_default()) + } + PhpMixed::Bool(_) => vec![shirabe_php_shim::var_export(option.get_default(), true)], + d if shirabe_php_shim::php_truthy(d) => { + vec![shirabe_php_shim::php_to_string(option.get_default())] + } + _ => vec![], + }; + let defaults_xml = object_xml.append_child(dom.create_element("defaults")); + + if !defaults.is_empty() { + for default in defaults { + let default_xml = defaults_xml.append_child(dom.create_element("default")); + default_xml.append_child(dom.create_text_node(&default)); + } + } + } + + if option.is_negatable() { + let object_xml = dom.append_child(dom.create_element("option")); + object_xml.set_attribute("name", &format!("--no-{}", option.get_name())); + object_xml.set_attribute("shortcut", ""); + object_xml.set_attribute("accept_value", "0"); + object_xml.set_attribute("is_value_required", "0"); + object_xml.set_attribute("is_multiple", "0"); + let description_xml = object_xml.append_child(dom.create_element("description")); + description_xml.append_child( + dom.create_text_node(&format!("Negate the \"--{}\" option", option.get_name())), + ); + } + + dom + } + + /// Helper used by the default-value branches of getInputArgumentDocument / + /// getInputOptionDocument when the default is an array (returns it verbatim). + fn default_values_as_strings(&self, default: &PhpMixed) -> Vec { + match default { + PhpMixed::List(list) => list.iter().map(shirabe_php_shim::php_to_string).collect(), + PhpMixed::Array(arr) => arr.values().map(shirabe_php_shim::php_to_string).collect(), + _ => vec![], + } + } +} + +impl DescriptorInterface for XmlDescriptor { + fn describe( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> anyhow::Result<()> { + Descriptor::describe(self, output, object, options) + } +} + +impl Descriptor for XmlDescriptor { + fn output(&self) -> std::rc::Rc> { + self.output.clone().unwrap() + } + + fn set_output(&mut self, output: std::rc::Rc>) { + self.output = Some(output); + } + + fn describe_input_argument( + &mut self, + argument: &InputArgument, + options: IndexMap, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_input_argument(self, argument, options) + } + + fn describe_input_option( + &mut self, + option: &InputOption, + options: IndexMap, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_input_option(self, option, options) + } + + fn describe_input_definition( + &mut self, + definition: &InputDefinition, + options: IndexMap, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_input_definition(self, definition, options) + } + + fn describe_command( + &mut self, + command: &dyn Command, + options: IndexMap, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_command(self, command, options) + } + + fn describe_application( + &mut self, + application: std::rc::Rc>, + options: IndexMap, + ) -> anyhow::Result<()> { + XmlDescriptor::describe_application(self, application, options) + } +} diff --git a/crates/shirabe-symfony-console/src/exception.rs b/crates/shirabe-symfony-console/src/exception.rs new file mode 100644 index 00000000..356b5955 --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception.rs @@ -0,0 +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-symfony-console/src/exception/command_not_found_exception.rs b/crates/shirabe-symfony-console/src/exception/command_not_found_exception.rs new file mode 100644 index 00000000..4b27c5e6 --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/command_not_found_exception.rs @@ -0,0 +1,30 @@ +//! ref: composer/vendor/symfony/console/Exception/CommandNotFoundException.php + +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct CommandNotFoundException { + inner: shirabe_php_shim::InvalidArgumentException, + alternatives: Vec, +} + +impl CommandNotFoundException { + pub fn new(message: String, alternatives: Vec, code: i64) -> Self { + Self { + inner: shirabe_php_shim::InvalidArgumentException::with_code(message, code), + alternatives, + } + } + + pub fn get_alternatives(&self) -> &Vec { + &self.alternatives + } +} + +shirabe_php_shim::impl_php_exception!( + CommandNotFoundException, + inner, + r"Symfony\Component\Console\Exception\CommandNotFoundException" +); + +impl ExceptionInterface for CommandNotFoundException {} diff --git a/crates/shirabe-symfony-console/src/exception/exception_interface.rs b/crates/shirabe-symfony-console/src/exception/exception_interface.rs new file mode 100644 index 00000000..bb2200f6 --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/exception_interface.rs @@ -0,0 +1,3 @@ +//! ref: composer/vendor/symfony/console/Exception/ExceptionInterface.php + +pub trait ExceptionInterface: shirabe_php_shim::Throwable {} diff --git a/crates/shirabe-symfony-console/src/exception/invalid_argument_exception.rs b/crates/shirabe-symfony-console/src/exception/invalid_argument_exception.rs new file mode 100644 index 00000000..ced79b44 --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/invalid_argument_exception.rs @@ -0,0 +1,20 @@ +//! ref: composer/vendor/symfony/console/Exception/InvalidArgumentException.php + +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct InvalidArgumentException(pub shirabe_php_shim::InvalidArgumentException); + +impl InvalidArgumentException { + pub fn new(message: String) -> Self { + Self(shirabe_php_shim::InvalidArgumentException::new(message)) + } +} + +shirabe_php_shim::impl_php_exception!( + InvalidArgumentException, + 0, + r"Symfony\Component\Console\Exception\InvalidArgumentException" +); + +impl ExceptionInterface for InvalidArgumentException {} diff --git a/crates/shirabe-symfony-console/src/exception/invalid_option_exception.rs b/crates/shirabe-symfony-console/src/exception/invalid_option_exception.rs new file mode 100644 index 00000000..04de3d42 --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/invalid_option_exception.rs @@ -0,0 +1,21 @@ +//! ref: composer/vendor/symfony/console/Exception/InvalidOptionException.php + +use super::exception_interface::ExceptionInterface; +use shirabe_php_shim::InvalidArgumentException; + +#[derive(Debug)] +pub struct InvalidOptionException(pub InvalidArgumentException); + +impl InvalidOptionException { + pub fn new(message: String) -> Self { + Self(InvalidArgumentException::new(message)) + } +} + +shirabe_php_shim::impl_php_exception!( + InvalidOptionException, + 0, + r"Symfony\Component\Console\Exception\InvalidOptionException" +); + +impl ExceptionInterface for InvalidOptionException {} diff --git a/crates/shirabe-symfony-console/src/exception/logic_exception.rs b/crates/shirabe-symfony-console/src/exception/logic_exception.rs new file mode 100644 index 00000000..03d9241f --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/logic_exception.rs @@ -0,0 +1,20 @@ +//! ref: composer/vendor/symfony/console/Exception/LogicException.php + +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct LogicException(pub shirabe_php_shim::LogicException); + +impl LogicException { + pub fn new(message: String) -> Self { + Self(shirabe_php_shim::LogicException::new(message)) + } +} + +shirabe_php_shim::impl_php_exception!( + LogicException, + 0, + r"Symfony\Component\Console\Exception\LogicException" +); + +impl ExceptionInterface for LogicException {} diff --git a/crates/shirabe-symfony-console/src/exception/missing_input_exception.rs b/crates/shirabe-symfony-console/src/exception/missing_input_exception.rs new file mode 100644 index 00000000..f9ddc0e6 --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/missing_input_exception.rs @@ -0,0 +1,21 @@ +//! ref: composer/vendor/symfony/console/Exception/MissingInputException.php + +use super::exception_interface::ExceptionInterface; +use super::runtime_exception::RuntimeException; + +#[derive(Debug)] +pub struct MissingInputException(pub RuntimeException); + +impl MissingInputException { + pub fn new(message: String) -> Self { + Self(RuntimeException::new(message)) + } +} + +shirabe_php_shim::impl_php_exception!( + MissingInputException, + 0, + r"Symfony\Component\Console\Exception\MissingInputException" +); + +impl ExceptionInterface for MissingInputException {} diff --git a/crates/shirabe-symfony-console/src/exception/namespace_not_found_exception.rs b/crates/shirabe-symfony-console/src/exception/namespace_not_found_exception.rs new file mode 100644 index 00000000..31da0305 --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/namespace_not_found_exception.rs @@ -0,0 +1,21 @@ +//! ref: composer/vendor/symfony/console/Exception/NamespaceNotFoundException.php + +use super::command_not_found_exception::CommandNotFoundException; +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct NamespaceNotFoundException(pub CommandNotFoundException); + +impl NamespaceNotFoundException { + pub fn new(message: String, alternatives: Vec, code: i64) -> Self { + Self(CommandNotFoundException::new(message, alternatives, code)) + } +} + +shirabe_php_shim::impl_php_exception!( + NamespaceNotFoundException, + 0, + r"Symfony\Component\Console\Exception\NamespaceNotFoundException" +); + +impl ExceptionInterface for NamespaceNotFoundException {} diff --git a/crates/shirabe-symfony-console/src/exception/runtime_exception.rs b/crates/shirabe-symfony-console/src/exception/runtime_exception.rs new file mode 100644 index 00000000..12e5b79c --- /dev/null +++ b/crates/shirabe-symfony-console/src/exception/runtime_exception.rs @@ -0,0 +1,20 @@ +//! ref: composer/vendor/symfony/console/Exception/RuntimeException.php + +use super::exception_interface::ExceptionInterface; + +#[derive(Debug)] +pub struct RuntimeException(pub shirabe_php_shim::RuntimeException); + +impl RuntimeException { + pub fn new(message: String) -> Self { + Self(shirabe_php_shim::RuntimeException::new(message)) + } +} + +shirabe_php_shim::impl_php_exception!( + RuntimeException, + 0, + r"Symfony\Component\Console\Exception\RuntimeException" +); + +impl ExceptionInterface for RuntimeException {} diff --git a/crates/shirabe-symfony-console/src/formatter.rs b/crates/shirabe-symfony-console/src/formatter.rs new file mode 100644 index 00000000..716d3fbe --- /dev/null +++ b/crates/shirabe-symfony-console/src/formatter.rs @@ -0,0 +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-symfony-console/src/formatter/output_formatter.rs b/crates/shirabe-symfony-console/src/formatter/output_formatter.rs new file mode 100644 index 00000000..bd7259a1 --- /dev/null +++ b/crates/shirabe-symfony-console/src/formatter/output_formatter.rs @@ -0,0 +1,356 @@ +//! ref: composer/vendor/symfony/console/Formatter/OutputFormatter.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::formatter::output_formatter_interface::OutputFormatterInterface; +use crate::formatter::output_formatter_style::OutputFormatterStyle; +use crate::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; +use crate::formatter::output_formatter_style_stack::OutputFormatterStyleStack; +use crate::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface; +use shirabe_php_shim::php_regex; +use shirabe_symfony_string::b; + +/// Formatter class for console output. +#[derive(Debug)] +pub struct OutputFormatter { + decorated: bool, + // PHP objects have reference semantics: get_style must hand out the shared + // style instance, so each style is held behind an Rc> handle. + styles: indexmap::IndexMap< + String, + std::rc::Rc>>, + >, + style_stack: OutputFormatterStyleStack, +} + +impl OutputFormatter { + /// Escapes "<" and ">" special chars in given text. + pub fn escape(text: &str) -> anyhow::Result { + let text = + shirabe_php_shim::preg_replace(php_regex!("/([^\\\\]|^)([<>])/"), "$1\\\\$2", text); + + Ok(Self::escape_trailing_backslash(&text)) + } + + /// 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 + } + + /// Initializes console output formatter. + /// + /// `styles` is an array of "name => FormatterStyle" instances. + pub fn new( + decorated: bool, + styles: indexmap::IndexMap>, + ) -> 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 get_style_stack(&self) -> &OutputFormatterStyleStack { + &self.style_stack + } + + /// Tries to create new style instance from string. + fn create_style_from_string( + &self, + string: &str, + ) -> anyhow::Result>> { + if let Some(style) = self.styles.get(string) { + return Ok(Some(style.borrow().clone_box())); + } + + let mut matches: Vec> = vec![]; + if shirabe_php_shim::preg_match_all_set_order( + php_regex!("/([^=]+)=([^;]+)(;|$)/"), + 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 = 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(php_regex!("{\\\\([<>])}"), "$1", &r#match[1]); + style.set_href(&url); + } else if r#match[0] == "options" { + let mut options = shirabe_php_shim::preg_match_all( + php_regex!("([^,;]+)"), + &shirabe_php_shim::strtolower(&r#match[1]), + ); + 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))) + } + + /// Applies current style from stack to text, if must be applied. + fn apply_current_style( + &mut self, + text: &str, + current: &str, + width: i64, + current_line_length: &mut i64, + ) -> String { + if text.is_empty() { + return String::new(); + } + + 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> = vec![]; + shirabe_php_shim::preg_match(php_regex!("~(\\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) + } + + 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 OutputFormatterInterface for OutputFormatter { + fn set_decorated(&mut self, decorated: bool) { + self.decorated = decorated; + } + + fn is_decorated(&self) -> bool { + self.decorated + } + + fn set_style(&mut self, name: &str, style: Box) { + self.styles.insert( + shirabe_php_shim::strtolower(name), + std::rc::Rc::new(std::cell::RefCell::new(style)), + ); + } + + fn has_style(&self, name: &str) -> bool { + self.styles + .contains_key(&shirabe_php_shim::strtolower(name)) + } + + fn get_style( + &self, + name: &str, + ) -> anyhow::Result>>> + { + if !self.has_style(name) { + return Err(InvalidArgumentException::new(format!( + "Undefined style: \"{}\".", + shirabe_php_shim::PhpMixed::String(name.to_string()), + )) + .into()); + } + + Ok(std::rc::Rc::clone( + &self.styles[&shirabe_php_shim::strtolower(name)], + )) + } + + fn format(&mut self, message: Option<&str>) -> anyhow::Result> { + self.format_and_wrap(message, 0) + } +} + +impl WrappableOutputFormatterInterface for OutputFormatter { + fn format_and_wrap( + &mut self, + message: Option<&str>, + width: i64, + ) -> anyhow::Result> { + let message = match message { + None => return Ok(Some(String::new())), + Some(message) => message, + }; + + let mut offset: i64 = 0; + let mut output = String::new(); + // Accurate PCRE patterns (possessive quantifiers `*+`), unsupported by the + // `regex` crate: + // let open_tag_regex = "[a-z](?:[^\\\\<>]*+ | \\\\.)*"; + // let close_tag_regex = "[a-z][^<>]*+"; + // TODO(phase-c): restore the possessive quantifiers once a PCRE-compatible + // engine is available; greedy quantifiers match the same tags here but may + // differ in pathological backtracking cases. + 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-symfony-console/src/formatter/output_formatter_interface.rs b/crates/shirabe-symfony-console/src/formatter/output_formatter_interface.rs new file mode 100644 index 00000000..f2346ceb --- /dev/null +++ b/crates/shirabe-symfony-console/src/formatter/output_formatter_interface.rs @@ -0,0 +1,29 @@ +//! ref: composer/vendor/symfony/console/Formatter/OutputFormatterInterface.php + +use crate::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; + +/// Formatter interface for console output. +pub trait OutputFormatterInterface: shirabe_php_shim::AsAny { + /// Sets the decorated flag. + fn set_decorated(&mut self, decorated: bool); + + /// Whether the output will decorate messages. + fn is_decorated(&self) -> bool; + + /// Sets a new style. + fn set_style(&mut self, name: &str, style: Box); + + /// Checks if output formatter has style with specified name. + fn has_style(&self, name: &str) -> bool; + + /// Gets style options from style with specified name. + /// + /// Throws InvalidArgumentException when style isn't defined. + fn get_style( + &self, + name: &str, + ) -> anyhow::Result>>>; + + /// Formats a message according to the given styles. + fn format(&mut self, message: Option<&str>) -> anyhow::Result>; +} diff --git a/crates/shirabe-symfony-console/src/formatter/output_formatter_style.rs b/crates/shirabe-symfony-console/src/formatter/output_formatter_style.rs new file mode 100644 index 00000000..2cf85e8e --- /dev/null +++ b/crates/shirabe-symfony-console/src/formatter/output_formatter_style.rs @@ -0,0 +1,106 @@ +//! ref: composer/vendor/symfony/console/Formatter/OutputFormatterStyle.php + +use crate::color::Color; +use crate::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; + +/// Formatter style class for defining styles. +#[derive(Debug, Clone)] +pub struct OutputFormatterStyle { + color: Color, + foreground: String, + background: String, + options: Vec, + href: Option, + handles_href_gracefully: Option, +} + +impl OutputFormatterStyle { + /// Initializes output formatter style. + pub fn new(foreground: Option<&str>, background: Option<&str>, options: Vec) -> 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).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) { + 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(std::ffi::OsStr::new("JetBrains-JediTerm")) + && (shirabe_php_shim::getenv("KONSOLE_VERSION").is_none_or(|v| v.is_empty()) + || shirabe_php_shim::getenv("KONSOLE_VERSION") + .map(|v| v.to_string_lossy().parse::().unwrap_or(0)) + .unwrap_or(0) + > 201100) + && shirabe_php_shim::PHP_SERVER + .lock() + .unwrap() + .get("IDEA_INITIAL_DIRECTORY") + .is_none(), + ); + } + + if let Some(href) = &self.href + && self.handles_href_gracefully == Some(true) + { + text = format!("\x1b]8;;{href}\x1b\\{text}\x1b]8;;\x1b\\"); + } + + self.color.apply(&text) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} diff --git a/crates/shirabe-symfony-console/src/formatter/output_formatter_style_interface.rs b/crates/shirabe-symfony-console/src/formatter/output_formatter_style_interface.rs new file mode 100644 index 00000000..dfbffa5d --- /dev/null +++ b/crates/shirabe-symfony-console/src/formatter/output_formatter_style_interface.rs @@ -0,0 +1,27 @@ +//! ref: composer/vendor/symfony/console/Formatter/OutputFormatterStyleInterface.php + +/// 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); + + /// Applies the style to a given text. + fn apply(&mut self, text: &str) -> String; + + /// Clones the style into a new boxed trait object. PHP shares the style + /// instance by reference; styles are immutable once configured, so cloning + /// is behaviorally equivalent here. + fn clone_box(&self) -> Box; +} diff --git a/crates/shirabe-symfony-console/src/formatter/output_formatter_style_stack.rs b/crates/shirabe-symfony-console/src/formatter/output_formatter_style_stack.rs new file mode 100644 index 00000000..f85b80f1 --- /dev/null +++ b/crates/shirabe-symfony-console/src/formatter/output_formatter_style_stack.rs @@ -0,0 +1,100 @@ +//! ref: composer/vendor/symfony/console/Formatter/OutputFormatterStyleStack.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::formatter::output_formatter_style::OutputFormatterStyle; +use crate::formatter::output_formatter_style_interface::OutputFormatterStyleInterface; + +#[derive(Debug)] +pub struct OutputFormatterStyleStack { + styles: Vec>, + empty_style: Box, +} + +impl OutputFormatterStyleStack { + pub fn new(empty_style: Option>) -> 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) { + 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>, + ) -> anyhow::Result, InvalidArgumentException>> + { + if self.styles.is_empty() { + return Ok(Ok(self.empty_style.clone_box())); + } + + 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::new( + "Incorrectly nested style tag found.".to_string(), + ))) + } + + /// 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, + ) -> &mut Self { + self.empty_style = empty_style; + + self + } + + pub fn get_empty_style(&self) -> &dyn OutputFormatterStyleInterface { + self.empty_style.as_ref() + } + + pub fn reset(&mut self) { + self.styles = vec![]; + } +} diff --git a/crates/shirabe-symfony-console/src/formatter/wrappable_output_formatter_interface.rs b/crates/shirabe-symfony-console/src/formatter/wrappable_output_formatter_interface.rs new file mode 100644 index 00000000..39e6cea1 --- /dev/null +++ b/crates/shirabe-symfony-console/src/formatter/wrappable_output_formatter_interface.rs @@ -0,0 +1,13 @@ +//! ref: composer/vendor/symfony/console/Formatter/WrappableOutputFormatterInterface.php + +use crate::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>; +} diff --git a/crates/shirabe-symfony-console/src/helper.rs b/crates/shirabe-symfony-console/src/helper.rs new file mode 100644 index 00000000..8aed672f --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper.rs @@ -0,0 +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-symfony-console/src/helper/debug_formatter_helper.rs b/crates/shirabe-symfony-console/src/helper/debug_formatter_helper.rs new file mode 100644 index 00000000..f7fd2157 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/debug_formatter_helper.rs @@ -0,0 +1,175 @@ +//! ref: composer/vendor/symfony/console/Helper/DebugFormatterHelper.php + +use crate::helper::helper::Helper; +use crate::helper::helper_interface::HelperInterface; +use crate::helper::helper_set::HelperSet; +use indexmap::IndexMap; + +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, + 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, + }, + ); + + format!( + "{} {} {}\n", + self.get_border(id), + prefix, + message, + ) + } + + /// 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(&format!( + "{} {} ", + self.get_border(id), + error_prefix, + )); + self.started.get_mut(id).unwrap().err = true; + } + + message.push_str(&shirabe_php_shim::str_replace( + "\n", + &format!( + "\n{} {} ", + self.get_border(id), + error_prefix, + ), + 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(&format!( + "{} {} ", + self.get_border(id), + prefix, + )); + self.started.get_mut(id).unwrap().out = true; + } + + message.push_str(&shirabe_php_shim::str_replace( + "\n", + &format!( + "\n{} {} ", + self.get_border(id), + prefix, + ), + 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 format!( + "{}{} {} {}\n", + trailing_eol, + self.get_border(id), + prefix, + message, + ); + } + + let message = format!( + "{}{} {} {}\n", + trailing_eol, + self.get_border(id), + prefix, + message, + ); + + if let Some(session) = self.started.get_mut(id) { + session.out = false; + session.err = false; + } + + message + } + + fn get_border(&self, id: &str) -> String { + format!(" ", COLORS[self.started[id].border as usize]) + } +} + +impl HelperInterface for DebugFormatterHelper { + fn set_helper_set(&mut self, helper_set: Option>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "debug_formatter".to_string() + } +} diff --git a/crates/shirabe-symfony-console/src/helper/descriptor_helper.rs b/crates/shirabe-symfony-console/src/helper/descriptor_helper.rs new file mode 100644 index 00000000..23bb4f66 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/descriptor_helper.rs @@ -0,0 +1,119 @@ +//! ref: composer/vendor/symfony/console/Helper/DescriptorHelper.php + +use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface}; +use crate::descriptor::json_descriptor::JsonDescriptor; +use crate::descriptor::markdown_descriptor::MarkdownDescriptor; +use crate::descriptor::text_descriptor::TextDescriptor; +use crate::descriptor::xml_descriptor::XmlDescriptor; +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::helper::helper::Helper; +use crate::helper::helper_interface::HelperInterface; +use crate::helper::helper_set::HelperSet; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; + +/// This class adds helper method to describe objects in various formats. +#[derive(Default)] +pub struct DescriptorHelper { + inner: Helper, + /// @var DescriptorInterface[] + descriptors: IndexMap>, +} + +// `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::>()) + .finish() + } +} + +impl DescriptorHelper { + pub fn new() -> Self { + let mut this = Self { + inner: Helper::default(), + descriptors: IndexMap::new(), + }; + this.register("txt", Box::new(TextDescriptor::default())) + .register("xml", Box::new(XmlDescriptor::default())) + .register("json", Box::new(JsonDescriptor::default())) + .register("md", Box::new(MarkdownDescriptor::default())); + 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( + &mut self, + output: std::rc::Rc>, + object: DescribableObject, + options: IndexMap, + ) -> anyhow::Result<()> { + let mut merged: IndexMap = 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::new(format!( + "Unsupported format \"{}\".", + format.clone() + )) + .into()); + } + + let descriptor = self.descriptors.get_mut(&format).unwrap(); + descriptor.describe(output, object, options) + } + + /// Registers a descriptor. + pub fn register( + &mut self, + format: &str, + descriptor: Box, + ) -> &mut Self { + self.descriptors.insert(format.to_string(), descriptor); + + self + } + + pub fn get_formats(&self) -> Vec { + self.descriptors.keys().cloned().collect() + } +} + +impl HelperInterface for DescriptorHelper { + fn set_helper_set(&mut self, helper_set: Option>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "descriptor".to_string() + } +} diff --git a/crates/shirabe-symfony-console/src/helper/formatter_helper.rs b/crates/shirabe-symfony-console/src/helper/formatter_helper.rs new file mode 100644 index 00000000..f4e1b643 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/formatter_helper.rs @@ -0,0 +1,99 @@ +//! ref: composer/vendor/symfony/console/Helper/FormatterHelper.php + +use crate::formatter::output_formatter::OutputFormatter; +use crate::helper::helper::Helper; +use crate::helper::helper_interface::HelperInterface; +use crate::helper::helper_set::HelperSet; + +/// The Formatter class provides helpers to format messages. +#[derive(Debug, Default)] +pub struct FormatterHelper { + inner: Helper, +} + +impl FormatterHelper { + /// Formats a message within a section. + pub fn format_section(&self, section: &str, message: &str, style: &str) -> String { + format!("<{}>[{}] {}", style, section, style, message) + } + + /// 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 = Vec::new(); + for message in &messages { + let message = OutputFormatter::escape(message).unwrap(); + lines.push(if large { + format!(" {} ", message) + } else { + format!(" {} ", message) + }); + len = std::cmp::max(Helper::width(&message) + (if large { 4 } else { 2 }), len); + } + + let mut messages: Vec = 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] = format!("<{}>{}", style, messages[i].clone(), style); + 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 set_helper_set(&mut self, helper_set: Option>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option>> { + 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), +} diff --git a/crates/shirabe-symfony-console/src/helper/helper.rs b/crates/shirabe-symfony-console/src/helper/helper.rs new file mode 100644 index 00000000..0af2830f --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/helper.rs @@ -0,0 +1,162 @@ +//! ref: composer/vendor/symfony/console/Helper/Helper.php + +use crate::formatter::output_formatter_interface::OutputFormatterInterface; +use crate::helper::helper_set::HelperSet; +use shirabe_php_shim::php_regex; +use shirabe_symfony_string::unicode_string::UnicodeString; + +/// Helper is the base class for all helper classes. +#[derive(Debug, Default)] +pub struct Helper { + pub(crate) helper_set: Option>>, +} + +impl Helper { + pub fn set_helper_set( + &mut self, + helper_set: Option>>, + ) { + self.helper_set = helper_set; + } + + pub fn get_helper_set(&self) -> Option>> { + 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(php_regex!("//u"), string, &mut Vec::new()) { + 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(php_regex!("//u"), string, &mut Vec::new()) { + 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) -> 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 { + // [threshold, label, divisor?] + let time_formats: [(f64, &str, Option); 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 + && ((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 format!("{:.1} GiB", memory as f64 / 1024.0 / 1024.0 / 1024.0); + } + + if memory >= 1024 * 1024 { + return format!("{:.1} MiB", memory as f64 / 1024.0 / 1024.0); + } + + if memory >= 1024 { + return format!("{} KiB", memory / 1024); + } + + format!("{} B", 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(php_regex!("/\u{1b}\\[[^m]*m/"), "", &string); + // remove terminal hyperlinks + let string = shirabe_php_shim::preg_replace( + php_regex!("/\u{1b}]8;[^;]*;[^\u{1b}]*\u{1b}\\\\/"), + "", + &string, + ); + formatter.set_decorated(is_decorated); + + string + } +} diff --git a/crates/shirabe-symfony-console/src/helper/helper_interface.rs b/crates/shirabe-symfony-console/src/helper/helper_interface.rs new file mode 100644 index 00000000..41c0b680 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/helper_interface.rs @@ -0,0 +1,15 @@ +//! ref: composer/vendor/symfony/console/Helper/HelperInterface.php + +use crate::helper::helper_set::HelperSet; + +/// 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>>); + + /// Gets the helper set associated with this helper. + fn get_helper_set(&self) -> Option>>; + + /// Returns the canonical name of this helper. + fn get_name(&self) -> String; +} diff --git a/crates/shirabe-symfony-console/src/helper/helper_set.rs b/crates/shirabe-symfony-console/src/helper/helper_set.rs new file mode 100644 index 00000000..bf64f8b2 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/helper_set.rs @@ -0,0 +1,76 @@ +//! ref: composer/vendor/symfony/console/Helper/HelperSet.php + +use crate::helper::debug_formatter_helper::DebugFormatterHelper; +use crate::helper::formatter_helper::FormatterHelper; +use crate::helper::helper_interface::HelperInterface; +use crate::helper::process_helper::ProcessHelper; +use crate::helper::question_helper::QuestionHelper; + +/// HelperSet represents a set of helpers to be used with a command. +/// +/// Symfony lets arbitrary helpers be registered by name, but Composer only ever uses the four +/// helpers `Application::getDefaultHelperSet()` installs. This port closes the set to exactly those +/// four, instantiates them in the argument-less constructor, and exposes them through typed getters +/// instead of Symfony's string-keyed `get()`/`has()`/`set()`. +/// +/// TODO(plugin): a plugin-defined custom command may register extra helpers dynamically via +/// `getApplication()->getHelperSet()`. Restoring that path (a `set()` equivalent plus name-based +/// lookup) is deferred until the plugin API is implemented. +#[derive(Debug)] +pub struct HelperSet { + formatter_helper: std::rc::Rc>, + debug_formatter_helper: std::rc::Rc>, + process_helper: std::rc::Rc>, + question_helper: std::rc::Rc>, +} + +impl HelperSet { + /// Builds the fixed set of helpers and wires each one's back-reference to the owning set, + /// mirroring the `$helper->setHelperSet($this)` call PHP's `HelperSet::set()` performs. + pub fn new() -> std::rc::Rc> { + let formatter_helper = + std::rc::Rc::new(std::cell::RefCell::new(FormatterHelper::default())); + let debug_formatter_helper = + std::rc::Rc::new(std::cell::RefCell::new(DebugFormatterHelper::default())); + let process_helper = std::rc::Rc::new(std::cell::RefCell::new(ProcessHelper::default())); + let question_helper = std::rc::Rc::new(std::cell::RefCell::new(QuestionHelper::default())); + + let this = std::rc::Rc::new(std::cell::RefCell::new(HelperSet { + formatter_helper: formatter_helper.clone(), + debug_formatter_helper: debug_formatter_helper.clone(), + process_helper: process_helper.clone(), + question_helper: question_helper.clone(), + })); + + formatter_helper + .borrow_mut() + .set_helper_set(Some(this.clone())); + debug_formatter_helper + .borrow_mut() + .set_helper_set(Some(this.clone())); + process_helper + .borrow_mut() + .set_helper_set(Some(this.clone())); + question_helper + .borrow_mut() + .set_helper_set(Some(this.clone())); + + this + } + + pub fn get_formatter(&self) -> std::rc::Rc> { + self.formatter_helper.clone() + } + + pub fn get_debug_formatter(&self) -> std::rc::Rc> { + self.debug_formatter_helper.clone() + } + + pub fn get_process(&self) -> std::rc::Rc> { + self.process_helper.clone() + } + + pub fn get_question(&self) -> std::rc::Rc> { + self.question_helper.clone() + } +} diff --git a/crates/shirabe-symfony-console/src/helper/process_helper.rs b/crates/shirabe-symfony-console/src/helper/process_helper.rs new file mode 100644 index 00000000..be1c1a84 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/process_helper.rs @@ -0,0 +1,310 @@ +//! ref: composer/vendor/symfony/console/Helper/ProcessHelper.php + +use crate::helper::debug_formatter_helper::DebugFormatterHelper; +use crate::helper::helper::Helper; +use crate::helper::helper_interface::HelperInterface; +use crate::helper::helper_set::HelperSet; +use crate::output::ConsoleOutputInterface; +use crate::output::output_interface::{self, OutputInterface}; +use shirabe_symfony_process::exception::process_failed_exception::ProcessFailedException; +use shirabe_symfony_process::process::Process; + +/// 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), +} + +#[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: std::rc::Rc>, + cmd: ProcessHelperCmd, + error: Option<&str>, + callback: Option>, + verbosity: i64, + ) -> anyhow::Result { + // `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(); }`. ConsoleOutput is the only OutputInterface + // implementor that also implements ConsoleOutputInterface, so the check + // reduces to a downcast to the concrete type. + let output: std::rc::Rc> = { + let redirected = shirabe_php_shim::AsAny::as_any(&*output.borrow()) + .downcast_ref::() + .map(|console| console.get_error_output()); + redirected.unwrap_or(output) + }; + + let formatter: std::rc::Rc> = self + .get_helper_set() + .unwrap() + .borrow() + .get_debug_formatter(); + + // 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 = cmd + .iter() + .map(|element| match element { + ProcessHelperCmdElement::String(s) => s.clone(), + ProcessHelperCmdElement::Process(_) => unreachable!(), + }) + .collect(); + process = Process::new( + command, + None, + None, + shirabe_php_shim::PhpMixed::Null, + 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::new(format!( + "Invalid command provided to \"{}()\": 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()), + ))); + } + } + + if verbosity <= output.borrow().get_verbosity() { + let started = Self::formatter_start( + &formatter, + &shirabe_php_shim::spl_object_hash(&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. + let env: indexmap::IndexMap = cmd + .iter() + .enumerate() + .filter_map(|(i, element)| match element { + ProcessHelperCmdElement::String(s) => { + Some((i.to_string(), shirabe_php_shim::PhpMixed::String(s.clone()))) + } + ProcessHelperCmdElement::Process(_) => None, + }) + .collect(); + let callback: Option bool>> = callback.map(|mut cb| { + Box::new(move |r#type: &str, buffer: &str| -> bool { + cb(r#type, buffer); + false + }) as Box bool> + }); + process.run(callback, env)?; + + if verbosity <= output.borrow().get_verbosity() { + let message = if process.is_successful() { + "Command ran successfully".to_string() + } else { + format!( + "{} 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), + &message, + process.is_successful(), + ); + output + .borrow() + .write(&[stopped], false, output_interface::OUTPUT_NORMAL); + } + + if !process.is_successful() + && let Some(error) = error + { + output.borrow().writeln( + &[format!("{}", self.escape_string(error))], + 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: std::rc::Rc>, + cmd: ProcessHelperCmd, + error: Option<&str>, + callback: Option>, + ) -> anyhow::Result { + let mut process = self.run( + output, + cmd, + error, + callback, + output_interface::VERBOSITY_VERY_VERBOSE, + )?; + + if !process.is_successful() { + anyhow::bail!(ProcessFailedException::new(&mut process)?); + } + + Ok(process) + } + + /// Wraps a Process callback to add debugging output. + pub fn wrap_callback( + &self, + output: std::rc::Rc>, + process: &Process, + mut callback: Option>, + ) -> Box { + // PHP: `if ($output instanceof ConsoleOutputInterface) { $output = + // $output->getErrorOutput(); }`. ConsoleOutput is the only OutputInterface + // implementor that also implements ConsoleOutputInterface, so the check + // reduces to a downcast to the concrete type. + let output: std::rc::Rc> = { + let redirected = shirabe_php_shim::AsAny::as_any(&*output.borrow()) + .downcast_ref::() + .map(|console| console.get_error_output()); + redirected.unwrap_or(output) + }; + + let formatter: std::rc::Rc> = self + .get_helper_set() + .unwrap() + .borrow() + .get_debug_formatter(); + + let object_hash = shirabe_php_shim::spl_object_hash(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) + } + + fn formatter_start( + formatter: &std::rc::Rc>, + id: &str, + message: &str, + ) -> String { + formatter.borrow_mut().start(id, message, "RUN") + } + + fn formatter_stop( + formatter: &std::rc::Rc>, + id: &str, + message: &str, + successful: bool, + ) -> String { + formatter.borrow_mut().stop(id, message, successful, "RES") + } + + fn formatter_progress( + formatter: &std::rc::Rc>, + id: &str, + buffer: &str, + error: bool, + ) -> String { + formatter + .borrow_mut() + .progress(id, buffer, error, "OUT", "ERR") + } +} + +impl HelperInterface for ProcessHelper { + fn set_helper_set(&mut self, helper_set: Option>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "process".to_string() + } +} diff --git a/crates/shirabe-symfony-console/src/helper/progress_bar.rs b/crates/shirabe-symfony-console/src/helper/progress_bar.rs new file mode 100644 index 00000000..44e144cf --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/progress_bar.rs @@ -0,0 +1,835 @@ +//! ref: composer/vendor/symfony/console/Helper/ProgressBar.php + +use crate::cursor::Cursor; +use crate::exception::logic_exception::LogicException; +use crate::helper::helper::Helper; +use crate::output::ConsoleOutputInterface; +use crate::output::ConsoleSectionOutput; +use crate::output::OutputInterface; +use crate::output::output_interface; +use crate::terminal::Terminal; +use indexmap::IndexMap; + +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, + &std::rc::Rc>, + ) -> anyhow::Result>, +>; + +/// The ProgressBar provides helpers to display progress output. +#[derive(Debug)] +pub struct ProgressBar { + bar_width: i64, + bar_char: Option, + empty_bar_char: String, + progress_char: String, + format: Option, + internal_format: Option, + redraw_freq: Option, + write_count: i64, + last_write_time: f64, + min_seconds_between_redraws: f64, + max_seconds_between_redraws: f64, + output: std::rc::Rc>, + step: i64, + max: i64, + start_time: i64, + step_width: i64, + percent: f64, + messages: IndexMap, + overwrite: bool, + terminal: Terminal, + previous_message: Option, + cursor: Cursor, +} + +thread_local! { + static FORMATTERS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + static FORMATS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +impl ProgressBar { + /// `$max` Maximum steps (0 if unknown) + pub fn new( + output: std::rc::Rc>, + max: i64, + min_seconds_between_redraws: f64, + ) -> Self { + // PHP: `if ($output instanceof ConsoleOutputInterface) { $output = + // $output->getErrorOutput(); }`. ConsoleOutput is the only OutputInterface + // implementor that also implements ConsoleOutputInterface, so the check + // reduces to a downcast to the concrete type. + let output: std::rc::Rc> = { + let redirected = shirabe_php_shim::AsAny::as_any(&*output.borrow()) + .downcast_ref::() + .map(|console| console.get_error_output()); + redirected.unwrap_or(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 + } + + /// 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); + }); + } + + /// 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(|_| ()) + }) + } + + /// 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()); + }); + } + + /// Gets the format for a given name. + /// + /// `$name` The format name + pub fn get_format_definition(name: &str) -> Option { + 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() + }) + } + + /// 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 { + self.messages.get(name).cloned() + } + + pub fn get_start_time(&self) -> i64 { + self.start_time + } + + pub fn get_max_steps(&self) -> i64 { + 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 { + f64::floor(if self.max != 0 { + self.percent * self.bar_width as f64 + } else if self.redraw_freq.is_none() { + (((self.bar_width / 15).min(5) * 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 = size.max(1); + } + + 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) { + self.redraw_freq = freq.map(|freq| freq.max(1)); + } + + 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, + ) -> anyhow::Result> { + self.start(Some(max.unwrap_or({ + // 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) -> 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() - 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 = max.max(0); + 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() { + // PHP: `$this->output instanceof ConsoleSectionOutput`. Downcast the + // shared output handle to the concrete section type. + let output_ref = self.output.borrow(); + if let Some(section) = shirabe_php_shim::AsAny::as_any(&*output_ref) + .downcast_ref::() + { + 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 formatter = section.get_formatter(); + let message_line_length = Helper::width(&Helper::remove_decoration( + &mut *formatter.borrow_mut(), + message_line, + )); + if message_line_length > self.terminal.get_width() { + line_count += (message_line_length as f64 + / self.terminal.get_width() as f64) + .floor() as i64; + } + } + section.clear(Some(line_count)); + } else { + drop(output_ref); + 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(); + + self.output + .borrow() + .write(&[message], false, output_interface::OUTPUT_NORMAL); + self.write_count += 1; + } + + 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 { + let mut formatters: IndexMap = IndexMap::new(); + + formatters.insert( + "bar".to_string(), + Box::new( + |bar: &ProgressBar, + output: &std::rc::Rc>| { + 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( + &mut *output.borrow().get_formatter().borrow_mut(), + &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 + ) + )); + } + + Ok(Ok(shirabe_php_shim::PhpMixed::String(display))) + }, + ), + ); + + formatters.insert( + "elapsed".to_string(), + Box::new( + |bar: &ProgressBar, + _output: &std::rc::Rc>| { + 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: &std::rc::Rc>| { + if bar.get_max_steps() == 0 { + return Ok(Err(LogicException::new("Unable to display the remaining time if the maximum number of steps is not set.".to_string()))); + } + + 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: &std::rc::Rc>| { + if bar.get_max_steps() == 0 { + return Ok(Err(LogicException::new("Unable to display the estimated time if the maximum number of steps is not set.".to_string()))); + } + + 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: &std::rc::Rc>| { + 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: &std::rc::Rc>| { + 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: &std::rc::Rc>| { + Ok(Ok(shirabe_php_shim::PhpMixed::Int(bar.get_max_steps()))) + }, + ), + ); + + formatters.insert( + "percent".to_string(), + Box::new( + |bar: &ProgressBar, + _output: &std::rc::Rc>| { + Ok(Ok(shirabe_php_shim::PhpMixed::Float( + (bar.get_progress_percent() * 100.0).floor(), + ))) + }, + ), + ); + + formatters + } + + fn init_formats() -> IndexMap { + let mut formats: IndexMap = 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 { + 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 = shirabe_php_shim::explode("\n", &line) + .iter() + .map(|sub_line| { + Helper::width(&Helper::remove_decoration( + &mut *self.output.borrow().get_formatter().borrow_mut(), + &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 { + let format = self.format.clone().unwrap_or_default(); + + // $callback in PHP, expressed as a closure over $this and the matches. + let callback = |matches: &[Option]| -> anyhow::Result { + 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) + }); + formatter_result?? + } 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) => { + format!("{}", f) + } + other => format!("{}", other), + }) + }; + + shirabe_php_shim::preg_replace_callback(regex, callback, &format) + } +} diff --git a/crates/shirabe-symfony-console/src/helper/question_helper.rs b/crates/shirabe-symfony-console/src/helper/question_helper.rs new file mode 100644 index 00000000..6ee83702 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/question_helper.rs @@ -0,0 +1,916 @@ +//! ref: composer/vendor/symfony/console/Helper/QuestionHelper.php + +use crate::cursor::Cursor; +use crate::exception::missing_input_exception::MissingInputException; +use crate::exception::runtime_exception::RuntimeException; +use crate::formatter::output_formatter::OutputFormatter; +use crate::formatter::output_formatter_style::OutputFormatterStyle; +use crate::helper::formatter_helper::FormatBlockMessages; +use crate::helper::helper::Helper; +use crate::helper::helper_interface::HelperInterface; +use crate::helper::helper_set::HelperSet; +use crate::input::input_interface::InputInterface; +use crate::output::console_output::ConsoleOutput; +use crate::output::console_output_interface::ConsoleOutputInterface; +use crate::output::console_section_output::ConsoleSectionOutput; +use crate::output::output_interface; +use crate::output::output_interface::OutputInterface; +use crate::question::ChoiceQuestion; +use crate::question::QuestionInterface; +use crate::terminal::Terminal; +use shirabe_php_shim::PhpMixed; +use shirabe_symfony_string::s; + +/// 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, +} + +/// self::$stty +static STTY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); +/// self::$stdinIsInteractive +static STDIN_IS_INTERACTIVE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// PHP dispatches `$this->writePrompt()` / `$this->writeError()` virtually, and +/// `SymfonyQuestionHelper` overrides both protected methods. The embedded-super port expresses +/// that late binding as a trait: the template methods are provided here and reach the overridable +/// hooks through `Self`, while `inner()` reaches the base-class state. PHP has no such interface; +/// the invented name follows the `QuestionInterface` precedent. +pub trait QuestionHelperInterface { + fn inner(&self) -> &QuestionHelper; + + fn inner_mut(&mut self) -> &mut 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 + fn ask( + &mut self, + input: &mut dyn InputInterface, + output: std::rc::Rc>, + question: &impl QuestionInterface, + ) -> anyhow::Result> { + let mut output = output; + let error_output = { + let borrowed = output.borrow(); + (*borrowed) + .as_any() + .downcast_ref::() + .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.inner().get_default_answer(question))); + } + + if let Some(streamable) = input.as_streamable() + && let Some(stream) = streamable.get_stream() + { + self.inner_mut().input_stream = Some(stream); + } + + let result: anyhow::Result> = (|| { + if question.get_validator().is_none() { + return self.do_ask(std::rc::Rc::clone(&output), question); + } + + let interviewer = || self.do_ask(std::rc::Rc::clone(&output), question); + + self.validate_attempts(&interviewer, std::rc::Rc::clone(&output), question) + })(); + + let result = result?; + match result { + Ok(value) => Ok(Ok(value)), + Err(exception) => { + input.set_interactive(false); + + let fallback_output = self.inner().get_default_answer(question); + if matches!(fallback_output, PhpMixed::Null) { + return Ok(Err(exception)); + } + + Ok(Ok(fallback_output)) + } + } + } + + /// Asks the question to the user (PHP private; on the trait so it can late-bind the hooks). + /// + /// @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden + fn do_ask( + &self, + output: std::rc::Rc>, + question: &impl QuestionInterface, + ) -> anyhow::Result> { + self.write_prompt(std::rc::Rc::clone(&output), question); + + let input_stream = self + .inner() + .input_stream + .clone() + .unwrap_or_else(shirabe_php_shim::stdin); + let autocomplete = question.get_autocompleter_callback(); + + let ret: PhpMixed; + + if let Some(autocomplete) = autocomplete + && STTY.load(std::sync::atomic::Ordering::SeqCst) + && Terminal::has_stty_available() + { + let callback = autocomplete; + // 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 = match self.inner().autocomplete( + std::rc::Rc::clone(&output), + question, + &input_stream, + &callback, + ) { + Ok(value) => value, + Err(exception) => return Ok(Err(exception)), + }; + ret = PhpMixed::String(if question.is_trimmable() { + shirabe_php_shim::trim(&autocomplete, None) + } else { + autocomplete + }); + } else { + let mut r: PhpMixed = PhpMixed::Bool(false); + if question.is_hidden() { + match self.inner().get_hidden_response( + std::rc::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") + .cloned() + .unwrap_or(PhpMixed::Bool(true)); + + if !shirabe_php_shim::boolval(&is_blocked) { + shirabe_php_shim::stream_set_blocking(&input_stream, true); + } + + let read = self.inner().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::new("Aborted.".to_string()))); + } + r = read; + if question.is_trimmable() { + r = PhpMixed::String(shirabe_php_shim::trim(&r.to_string(), None)); + } + } + ret = r; + } + + let mut ret = ret; + { + let borrowed = output.borrow(); + if let Some(section_output) = + (*borrowed).as_any().downcast_ref::() + { + 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)) + } + + /// Validates an attempt (PHP private; on the trait so it can late-bind the hooks). + /// + /// @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>, + output: std::rc::Rc>, + question: &impl QuestionInterface, + ) -> anyhow::Result> { + let mut error: Option = 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(std::rc::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::with_code( + e.get_message().to_string(), + e.get_code(), + )); + } + } + } + + // throw $error; + Err(anyhow::Error::msg( + error + .map(|e| e.get_message().to_string()) + .unwrap_or_default(), + )) + } + + /// Outputs the question prompt (PHP protected; the overridable hook). + fn write_prompt( + &self, + output: std::rc::Rc>, + question: &impl QuestionInterface, + ) { + self.inner().write_prompt(output, question); + } + + /// Outputs an error message (PHP protected; the overridable hook). + fn write_error( + &self, + output: std::rc::Rc>, + error: &shirabe_php_shim::Exception, + ) { + self.inner().write_error(output, error); + } +} + +impl QuestionHelperInterface for QuestionHelper { + fn inner(&self) -> &QuestionHelper { + self + } + + fn inner_mut(&mut self) -> &mut QuestionHelper { + self + } +} + +impl QuestionHelper { + 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); + } + + fn get_default_answer(&self, question: &impl QuestionInterface) -> 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() { + let choices = choice_question.get_choices(); + + if !choice_question.is_multiselect() { + return choices + .get(&default.to_string()) + .cloned() + .unwrap_or(default); + } + + let default_parts = shirabe_php_shim::explode(",", &default.to_string()); + let mut resolved: indexmap::IndexMap = 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).cloned().unwrap_or(PhpMixed::String(v)); + resolved.insert(k.to_string(), value); + } + + return PhpMixed::Array(resolved); + } + + default + } + + /// Outputs the question prompt. + pub(crate) fn write_prompt( + &self, + output: std::rc::Rc>, + question: &impl QuestionInterface, + ) { + let mut message = question.get_question().to_string(); + + if let Some(choice_question) = question.as_choice() { + 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); + } + + pub(crate) fn format_choice_question_choices( + &self, + question: &ChoiceQuestion, + tag: &str, + ) -> Vec { + let mut messages: Vec = 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(format!( + " [<{tag}>{}{padding}] {}", + key.clone(), + value.clone(), + )); + } + + messages + } + + /// Outputs an error message. + pub(crate) fn write_error( + &self, + output: std::rc::Rc>, + error: &shirabe_php_shim::Exception, + ) { + let message = if let Some(helper_set) = self.get_helper_set() { + let formatter = helper_set.borrow().get_formatter(); + + formatter.borrow().format_block( + FormatBlockMessages::String(error.get_message().to_string()), + "error", + false, + ) + } else { + format!("{}", error.get_message()) + }; + + output + .borrow() + .writeln(&[message], output_interface::OUTPUT_NORMAL); + } + + /// Autocompletes a question. + fn autocomplete( + &self, + output: std::rc::Rc>, + question: &impl QuestionInterface, + input_stream: &shirabe_php_shim::PhpResource, + autocomplete: &dyn Fn(&str) -> Vec, + ) -> Result { + let cursor = Cursor::new(std::rc::Rc::clone(&output), Some(input_stream.clone())); + + 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 = 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 !shirabe_php_shim::feof(input_stream) { + while is_stdin + && Some(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 = shirabe_php_shim::fread(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)); + return Err(MissingInputException::new("Aborted.".to_string())); + } 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 = shirabe_php_shim::fread(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( + std::slice::from_ref(&remaining_characters), + 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(); + ofs = -1; + } + + if c.as_deref() == Some("\n") { + output.borrow().write( + &[c.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 = shirabe_php_shim::fread(input_stream, len).unwrap_or_default(); + c = Some(format!("{}{}", cur, extra)); + } + + let cur = c.clone().unwrap_or_default(); + output.borrow().write( + std::slice::from_ref(&cur), + 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() + && 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!( + "{}", + 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)); + + Ok(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, + output: std::rc::Rc>, + input_stream: &shirabe_php_shim::PhpResource, + trimmable: bool, + ) -> anyhow::Result> { + if cfg!(windows) { + let mut exe = format!( + "{}/../Resources/bin/hiddeninput.exe", + shirabe_php_shim::dir() + ); + + // handle code running from a phar + let mut tmp_exe: Option = 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::new( + "Unable to hide the response.".to_string(), + ))); + } + + let value = shirabe_php_shim::fgets(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::new("Aborted.".to_string()).into()); + } + }; + if trimmable { + value = shirabe_php_shim::trim(&value, None); + } + output + .borrow() + .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); + + Ok(Ok(value)) + } + + 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: &impl QuestionInterface, + ) -> PhpMixed { + if !question.is_multiline() { + let cp = self.set_io_codepage(); + let ret = shirabe_php_shim::fgets(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. + 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 { + let stream_meta_data = shirabe_php_shim::stream_get_meta_data(input_stream); + let seekable = stream_meta_data + .get("seekable") + .cloned() + .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::fopen(&uri, &mode).ok()?; + + // 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).unwrap_or(0); + shirabe_php_shim::rewind(input_stream); + shirabe_php_shim::stream_copy_to_stream(input_stream, &clone_stream); + shirabe_php_shim::fseek(input_stream, offset, shirabe_php_shim::SEEK_SET); + shirabe_php_shim::fseek(&clone_stream, offset, shirabe_php_shim::SEEK_SET); + } + + Some(clone_stream) + } + + /// Helper::substr proxy (inherited static helper). + fn substr(string: Option<&str>, from: i64, length: Option) -> String { + Helper::substr(string.unwrap_or(""), from, length) + } +} + +/// PHP `__FILE__` magic constant. The executing code lives in the Shirabe binary itself, which is +/// the closest analogue for a native executable; it never carries the `phar:` scheme, so the +/// phar-relocation branch in `get_hidden_response` correctly never triggers. +fn magic_file() -> String { + std::env::current_exe() + .expect("current executable path") + .display() + .to_string() +} + +impl HelperInterface for QuestionHelper { + fn set_helper_set(&mut self, helper_set: Option>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + self.get_name() + } +} diff --git a/crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs b/crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs new file mode 100644 index 00000000..9de40dd4 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs @@ -0,0 +1,163 @@ +//! ref: composer/vendor/symfony/console/Helper/SymfonyQuestionHelper.php + +use crate::formatter::output_formatter::OutputFormatter; +use crate::helper::question_helper::{QuestionHelper, QuestionHelperInterface}; +use crate::output::output_interface; +use crate::output::output_interface::OutputInterface; +use crate::question::QuestionInterface; +use crate::style::style_interface::StyleInterface; +use crate::style::symfony_style::SymfonyStyle; +use shirabe_php_shim::PhpMixed; +use std::ops::{Deref, DerefMut}; + +/// Symfony Style Guide compliant question helper. +#[derive(Debug, Default)] +pub struct SymfonyQuestionHelper { + inner: QuestionHelper, +} + +impl SymfonyQuestionHelper { + pub fn new() -> Self { + Self::default() + } + + fn get_eof_shortcut(&self) -> String { + if shirabe_php_shim::php_os_family() == "Windows" { + return "Ctrl+Z then Enter".to_string(); + } + + "Ctrl+D".to_string() + } +} + +impl QuestionHelperInterface for SymfonyQuestionHelper { + fn inner(&self) -> &QuestionHelper { + &self.inner + } + + fn inner_mut(&mut self) -> &mut QuestionHelper { + &mut self.inner + } + + /// {@inheritdoc} + fn write_prompt( + &self, + output: std::rc::Rc>, + question: &impl QuestionInterface, + ) { + let mut text = OutputFormatter::escape_trailing_backslash(question.get_question()); + let default = question.get_default(); + + if question.is_multiline() { + text += &format!(" (press {} to continue)", self.get_eof_shortcut()); + } + + // switch (true) + if matches!(default, PhpMixed::Null) { + text = format!(" {}:", text); + } else if question.as_confirmation().is_some() { + text = format!( + " {} (yes/no) [{}]:", + text, + if shirabe_php_shim::boolval(&default) { + "yes" + } else { + "no" + }, + ); + } else if let Some(choice_question) = question.as_choice().filter(|q| q.is_multiselect()) { + let choices = choice_question.get_choices(); + let default_parts = shirabe_php_shim::explode(",", &default.to_string()); + + let resolved: Vec = default_parts + .iter() + .map(|value| { + choices + .get(&shirabe_php_shim::trim(value, None)) + .map(|v| v.to_string()) + .unwrap() + }) + .collect(); + + text = format!( + " {} [{}]:", + text, + OutputFormatter::escape(&resolved.join(", ")).unwrap(), + ); + } else if let Some(choice_question) = question.as_choice() { + let choices = choice_question.get_choices(); + text = format!( + " {} [{}]:", + text, + OutputFormatter::escape( + &choices + .get(&default.to_string()) + .cloned() + .unwrap_or(default) + .to_string(), + ) + .unwrap(), + ); + } else { + text = format!( + " {} [{}]:", + text, + 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_choice() { + 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); + } + + /// {@inheritdoc} + fn write_error( + &self, + output: std::rc::Rc>, + error: &shirabe_php_shim::Exception, + ) { + { + let mut borrowed = output.borrow_mut(); + if let Some(style) = (*borrowed).as_any_mut().downcast_mut::() { + style.new_line(1); + style.error(PhpMixed::String(error.get_message().to_string())); + + return; + } + } + + self.inner.write_error(output, error); + } +} + +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-symfony-console/src/helper/table.rs b/crates/shirabe-symfony-console/src/helper/table.rs new file mode 100644 index 00000000..e2dec0c1 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/table.rs @@ -0,0 +1,1443 @@ +//! ref: composer/vendor/symfony/console/Helper/Table.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::runtime_exception::RuntimeException; +use crate::formatter::output_formatter::OutputFormatter; +use crate::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface; +use crate::helper::helper::Helper; +use crate::helper::table_cell::{TableCell, TableCellOption}; +use crate::helper::table_cell_style::TableCellStyle; +use crate::helper::table_rows::TableRows; +use crate::helper::table_separator::TableSeparator; +use crate::helper::table_style::TableStyle; +use crate::output::console_section_output::ConsoleSectionOutput; +use crate::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use shirabe_pcre::preg::Preg; +use shirabe_php_shim::{PhpMixed, php_regex}; + +/// A single cell within a table row. +/// +/// PHP types a cell as `TableCell|string|int|null` (scalars are stringified). Because +/// `TableSeparator extends TableCell`, a separator can in principle appear as a cell, so it has its +/// own variant; `is_table_cell` reports `true` for it, mirroring `instanceof TableCell`. +#[derive(Debug, Clone)] +pub enum Cell { + Null, + Value(String), + Cell(TableCell), + Separator(TableSeparator), +} + +impl Cell { + /// PHP `$cell instanceof TableCell` (true for `TableSeparator`, which extends `TableCell`). + fn is_table_cell(&self) -> bool { + matches!(self, Cell::Cell(_) | Cell::Separator(_)) + } + + /// PHP `$cell instanceof TableSeparator`. + fn is_table_separator(&self) -> bool { + matches!(self, Cell::Separator(_)) + } + + fn colspan(&self) -> i64 { + match self { + Cell::Cell(c) => c.get_colspan(), + Cell::Separator(s) => s.get_colspan(), + _ => 1, + } + } + + fn rowspan(&self) -> i64 { + match self { + Cell::Cell(c) => c.get_rowspan(), + Cell::Separator(s) => s.get_rowspan(), + _ => 1, + } + } + + fn style(&self) -> Option> { + match self { + Cell::Cell(c) => c.get_style(), + Cell::Separator(s) => s.get_style(), + _ => None, + } + } + + /// PHP `(string) $cell`. + fn to_php_string(&self) -> String { + match self { + Cell::Null | Cell::Separator(_) => String::new(), + Cell::Value(s) => s.clone(), + Cell::Cell(c) => c.to_string(), + } + } + + /// PHP truthiness / `!empty($cell)` for a cell value: only "" and "0" (and null) are falsy; + /// any object is truthy. + fn is_truthy(&self) -> bool { + match self { + Cell::Null => false, + Cell::Value(s) => !s.is_empty() && s != "0", + Cell::Cell(_) | Cell::Separator(_) => true, + } + } + + fn is_null(&self) -> bool { + matches!(self, Cell::Null) + } +} + +impl From for Cell { + fn from(value: String) -> Self { + Cell::Value(value) + } +} + +impl From<&str> for Cell { + fn from(value: &str) -> Self { + Cell::Value(value.to_string()) + } +} + +impl From for Cell { + fn from(value: TableCell) -> Self { + Cell::Cell(value) + } +} + +/// Bridges PHP-typed cell values built elsewhere in the codebase (where rows are still assembled as +/// `PhpMixed`) onto the typed cell. A cell is a scalar or null; collections stringify as PHP would. +impl From for Cell { + fn from(value: PhpMixed) -> Self { + match value { + PhpMixed::Null => Cell::Null, + other => Cell::Value(shirabe_php_shim::to_string(&other)), + } + } +} + +/// A table row. +/// +/// PHP types a row as `array|TableSeparator`. The header/body boundary that PHP creates +/// as a fresh `TableSeparator` and recognizes by object identity (`$divider === $row`) is modeled +/// here by its own [`Row::HeaderDivider`] variant rather than by reference identity: it counts as a +/// separator for layout (`is_table_separator`), but unlike a user-supplied [`Row::Separator`] it +/// renders no separator line — it only flips the header/first-row state during rendering. +#[derive(Debug, Clone)] +pub enum Row { + HeaderDivider, + Separator(TableSeparator), + Cells(Vec), +} + +impl Row { + /// PHP `$row instanceof TableSeparator` (also true for the internal header/body divider). + fn is_table_separator(&self) -> bool { + matches!(self, Row::HeaderDivider | Row::Separator(_)) + } + + /// PHP `$divider === $row`: identifies the internal header/body boundary marker. + fn is_divider(&self) -> bool { + matches!(self, Row::HeaderDivider) + } + + /// The row's cells, or an empty list for a separator (PHP `foreach` over a separator object, + /// which exposes no public properties, yields nothing). + fn cells(&self) -> Vec { + match self { + Row::Cells(cells) => cells.clone(), + _ => Vec::new(), + } + } + + /// PHP `!$row`: an empty cell array is falsy; a separator is an object and thus truthy. + fn is_truthy(&self) -> bool { + match self { + Row::Cells(cells) => !cells.is_empty(), + _ => true, + } + } + + /// PHP `isset($row[$i])`: `false` for a missing or null cell. + fn get_cell_isset(&self, index: i64) -> Option { + match self { + Row::Cells(cells) => match cells.get(index as usize) { + Some(Cell::Null) | None => None, + Some(cell) => Some(cell.clone()), + }, + _ => None, + } + } +} + +impl From> for Row { + fn from(cells: Vec) -> Self { + Row::Cells(cells) + } +} + +/// Bridges PHP-typed rows built elsewhere (still assembled as `PhpMixed`) onto the typed row. +impl From for Row { + fn from(value: PhpMixed) -> Self { + match value { + PhpMixed::List(items) => Row::Cells(items.into_iter().map(Cell::from).collect()), + PhpMixed::Array(map) => Row::Cells(map.into_values().map(Cell::from).collect()), + PhpMixed::Null => Row::Cells(Vec::new()), + other => Row::Cells(vec![Cell::from(other)]), + } + } +} + +/// A table or column style argument. PHP types it as `string|TableStyle`: either the name of a +/// registered style or a `TableStyle` instance. +#[derive(Debug, Clone)] +pub enum StyleName { + Name(String), + Style(TableStyle), +} + +impl From<&str> for StyleName { + fn from(name: &str) -> Self { + StyleName::Name(name.to_string()) + } +} + +impl From for StyleName { + fn from(name: String) -> Self { + StyleName::Name(name) + } +} + +impl From for StyleName { + fn from(style: TableStyle) -> Self { + StyleName::Style(style) + } +} + +/// PHP `$row[$index] = $value`: assigns at the integer key, growing the (dense) row with null +/// cells to reach `index` when necessary. +fn set_cell(row: &mut Vec, index: i64, value: Cell) { + let index = index as usize; + while row.len() <= index { + row.push(Cell::Null); + } + row[index] = value; +} + +/// Provides helpers to display a table. +#[derive(Debug)] +pub struct Table { + header_title: Option, + footer_title: Option, + + /// Table headers. + headers: Vec, + + /// Table rows. + rows: Vec, + horizontal: bool, + + /// Column widths cache. + effective_column_widths: IndexMap, + + /// Number of columns cache. + number_of_columns: Option, + + output: std::rc::Rc>, + + style: TableStyle, + + column_styles: IndexMap, + + /// User set column widths. + column_widths: IndexMap, + column_max_widths: IndexMap, + + 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>> { + static STYLES: std::sync::Mutex>> = + std::sync::Mutex::new(None); + &STYLES +} + +impl Table { + pub fn new(output: std::rc::Rc>) -> 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(StyleName::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> { + 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) { + return Ok(Ok(style.clone())); + } + + Ok(Err(InvalidArgumentException::new(format!( + "Style \"{}\" is not defined.", + name + )))) + } + + /// Sets table style. + /// + /// `$name` is the style name or a TableStyle instance. + pub fn set_style( + &mut self, + name: StyleName, + ) -> anyhow::Result> { + 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: StyleName, + ) -> anyhow::Result> { + 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) -> &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) -> &mut Self { + // PHP wraps a flat list of cells into a single header row. (Multi-row headers, which PHP + // also accepts, are not used in this codebase and are not modeled by the typed API.) + self.headers = if headers.is_empty() { + Vec::new() + } else { + vec![Row::Cells(headers)] + }; + + self + } + + pub fn set_rows(&mut self, rows: Vec) -> &mut Self { + self.rows = Vec::new(); + + self.add_rows(rows) + } + + pub fn add_rows(&mut self, rows: Vec) -> &mut Self { + for row in rows { + self.add_row(row); + } + + self + } + + pub fn add_row(&mut self, row: Row) -> &mut Self { + // PHP `array_values($row)` reindexing is the identity on a positional cell vector, and the + // "row must be an array or a TableSeparator" check is now guaranteed by the type. + self.rows.push(row); + + self + } + + /// Adds a row to the table, and re-renders the table. + pub fn append_row(&mut self, row: Row) -> anyhow::Result> { + if !Self::output_is_console_section(&self.output) { + return Ok(Err(RuntimeException::new(format!( + "Output should be an instance of \"{}\" when calling \"{}\".", + "Symfony\\Component\\Console\\Output\\ConsoleSectionOutput", + "Symfony\\Component\\Console\\Helper\\Table::appendRow", + )))); + } + + if self.rendered { + // TODO(phase-c): 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); + self.render(); + + Ok(Ok(self)) + } + + pub fn set_row(&mut self, column: i64, row: Vec) -> &mut Self { + // PHP indexes $this->rows by arbitrary key; sparse assignment over a positional Vec is not + // modeled and has no callers. + let _ = (column, row); + // TODO(phase-c): sparse `$this->rows[$column] = $row` over a positional row vector. + todo!() + } + + pub fn set_header_title(&mut self, title: Option) -> &mut Self { + self.header_title = title; + + self + } + + pub fn set_footer_title(&mut self, title: Option) -> &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 rows: Vec = if self.horizontal { + let mut horizontal_rows: IndexMap> = IndexMap::new(); + let header0 = self.headers.first().map(|h| h.cells()).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 row.is_table_separator() { + continue; + } + if let Some(cell) = row.get_cell_isset(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 c.is_table_cell() => c.colspan() >= 2, + _ => false, + }; + if is_title_noop { + // Noop, there is a "title" + } else { + let entry = horizontal_rows.get_mut(&i).unwrap(); + entry.push(Cell::Null); + } + } + } + } + horizontal_rows.into_values().map(Row::Cells).collect() + } else { + let mut merged = self.headers.clone(); + merged.push(Row::HeaderDivider); + merged.extend(self.rows.clone()); + 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 row.is_divider() { + is_header = false; + is_first_row = true; + + continue; + } + + if row.is_table_separator() { + self.render_row_separator(SEPARATOR_MID, None, None); + + continue; + } + + if !row.is_truthy() { + 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( + row.cells(), + self.style.get_cell_row_format(), + Some(self.style.get_cell_header_format()), + ); + } else { + self.render_row( + row.cells(), + 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, + title_format: Option, + ) { + 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 = (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::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, cell_format: String, first_cell_format: Option) { + 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::output::output_interface::OUTPUT_NORMAL, + ); + } + + /// Renders table cell with padding. + fn render_cell(&self, row: &[Cell], column: i64, cell_format: String) -> String { + let cell = row + .get(column as usize) + .cloned() + .unwrap_or(Cell::Value(String::new())); + let mut width = self.effective_column_widths[&column]; + if cell.is_table_cell() && cell.colspan() > 1 { + // add the width of the following columns(numbers of colspan). + for next_column in (column + 1)..=(column + cell.colspan() - 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 = cell.to_php_string(); + 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); + + if cell.is_table_separator() { + 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 cell.is_table_cell() && cell.style().is_some() { + let is_not_styled_by_tag = !Preg::is_match( + php_regex!("/^<(\\w+|(\\w+=[\\w,]+;?)*)>.+<\\/(\\w+|(\\w+=\\w+;?)*)?>$/"), + &cell_str, + ); + if is_not_styled_by_tag { + let cell_style = cell.style().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, "").is_some() { + content = + shirabe_php_shim::str_replace("", "", &content); + width -= shirabe_php_shim::strlen(""); + } + } + + pad_type = cell.style().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: &[Row]) { + let mut columns = vec![0i64]; + for row in rows { + if row.is_table_separator() { + continue; + } + + columns.push(self.get_number_of_columns(&row.cells())); + } + + self.number_of_columns = Some(*columns.iter().max().unwrap()); + } + + fn build_table_rows(&mut self, rows: Vec) -> TableRows { + let mut rows = rows; + let mut unmerged_rows: IndexMap>> = 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 = rows[row_key as usize].cells(); + for (column, cell) in current.iter().enumerate() { + let column = column as i64; + let mut cell = cell.clone(); + let colspan = if cell.is_table_cell() { + cell.colspan() + } else { + 1 + }; + + if self.column_max_widths.contains_key(&column) + && Helper::width(&self.remove_decoration(&cell.to_php_string())) + > self.column_max_widths[&column] + { + let wrapped = self.format_and_wrap( + &cell.to_php_string(), + self.column_max_widths[&column] * colspan, + ); + cell = Cell::Value(wrapped); + } + let cell_str = cell.to_php_string(); + 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::>(), + ); + cell = if cell.is_table_cell() { + Cell::Cell(TableCell::new2( + &escaped, + Self::table_cell_options_colspan(cell.colspan()), + )) + } else { + Cell::Value(escaped.clone()) + }; + let lines = shirabe_php_shim::explode( + eol, + &shirabe_php_shim::str_replace( + eol, + &format!("{}", eol), + &cell.to_php_string(), + ), + ); + for (line_key, line) in lines.into_iter().enumerate() { + let line_key = line_key as i64; + let mut line = Cell::Value(line); + if colspan > 1 { + line = Cell::Cell(TableCell::new2( + &line.to_php_string(), + Self::table_cell_options_colspan(colspan), + )); + } + if 0 == line_key { + let mut r = rows[row_key as usize].cells(); + set_cell(&mut r, column, line); + rows[row_key as usize] = Row::Cells(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(); + set_cell(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(). Here the row groups are + // precomputed eagerly to preserve behavior, then handed to TableRows. + let mut row_groups: Vec> = Vec::new(); + for (row_key, row) in rows.into_iter().enumerate() { + let row_key = row_key as i64; + let mut row_group: Vec = vec![if row.is_table_separator() { + row + } else { + Row::Cells(self.fill_cells(row.cells())) + }]; + + if let Some(extra) = unmerged_rows.get(&row_key) { + for r in extra.values() { + row_group.push(Row::Cells(self.fill_cells(r.clone()))); + } + } + 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(Row::Separator(TableSeparator::new())); + merged.extend(self.rows.clone()); + let mut number_of_rows = self.build_table_rows(merged).into_row_groups().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, line: i64) -> Vec { + let mut rows = rows; + let mut unmerged_rows: IndexMap> = IndexMap::new(); + let current = rows[line as usize].cells(); + for (column, cell) in current.iter().enumerate() { + let column = column as i64; + let cell = cell.clone(); + // PHP validates here that a cell is null, a TableCell, a scalar, or a __toString object. + // The `Cell` type makes every variant valid, so the InvalidArgumentException is dead. + if cell.is_table_cell() && cell.rowspan() > 1 { + let mut nb_lines = cell.rowspan() - 1; + let cell_str = cell.to_php_string(); + 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!("{}", eol), + &cell_str, + ), + ); + lines = exploded.into_iter().map(Cell::Value).collect(); + nb_lines = if (lines.len() as i64) > nb_lines { + shirabe_php_shim::substr_count(&cell_str, eol) + } else { + nb_lines + }; + + let mut r = rows[line as usize].cells(); + set_cell( + &mut r, + column, + Cell::Cell(TableCell::new2( + &lines[0].to_php_string(), + Self::table_cell_options_colspan_style(cell.colspan(), cell.style()), + )), + ); + rows[line as usize] = Row::Cells(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::>() { + let idx = unmerged_row_key - line; + let value = lines + .get(idx as usize) + .cloned() + .unwrap_or(Cell::Value(String::new())); + unmerged_rows.get_mut(&unmerged_row_key).unwrap().insert( + column, + Cell::Cell(TableCell::new2( + &value.to_php_string(), + Self::table_cell_options_colspan_style(cell.colspan(), cell.style()), + )), + ); + 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() + && matches!(rows[unmerged_row_key as usize], Row::Cells(_)) + && (self.get_number_of_columns(&rows[unmerged_row_key as usize].cells()) + + self.get_number_of_columns( + &unmerged_rows[&unmerged_row_key] + .values() + .cloned() + .collect::>(), + ) + <= self.number_of_columns.unwrap()); + if fits { + let mut target = rows[unmerged_row_key as usize].cells(); + 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] = Row::Cells(target); + } else { + let mut row = self.copy_row(&rows, unmerged_row_key - 1); + for (column, cell) in &unmerged_row { + if cell.is_truthy() { + set_cell(&mut row, *column, unmerged_row[column].clone()); + } + } + shirabe_php_shim::array_splice( + &mut rows, + unmerged_row_key, + Some(0), + vec![Row::Cells(row)], + ); + } + } + + rows + } + + /// fill cells for a row that contains colspan > 1. + fn fill_cells(&self, row: Vec) -> Vec { + let mut new_row: Vec = Vec::new(); + + for (column, cell) in row.iter().enumerate() { + let column = column as i64; + new_row.push(cell.clone()); + if cell.is_table_cell() && cell.colspan() > 1 { + for _position in (column + 1)..=(column + cell.colspan() - 1) { + // insert empty value at column position + new_row.push(Cell::Value(String::new())); + } + } + } + + if new_row.is_empty() { row } else { new_row } + } + + fn copy_row(&self, rows: &[Row], line: i64) -> Vec { + let mut row = rows[line as usize].cells(); + for cell in &mut row { + let cell_value = cell.clone(); + *cell = Cell::Value(String::new()); + if cell_value.is_table_cell() { + *cell = Cell::Cell(TableCell::new2( + "", + Self::table_cell_options_colspan(cell_value.colspan()), + )); + } + } + + row + } + + /// Gets number of columns by row. + fn get_number_of_columns(&self, row: &[Cell]) -> i64 { + let mut columns = row.len() as i64; + for column in row { + columns += if column.is_table_cell() { + column.colspan() - 1 + } else { + 0 + }; + } + + columns + } + + /// Gets list of columns for the given row. + fn get_row_columns(&self, row: &[Cell]) -> Vec { + let mut columns: Vec = (0..self.number_of_columns.unwrap()).collect(); + for (cell_key, cell) in row.iter().enumerate() { + let cell_key = cell_key as i64; + if cell.is_table_cell() && cell.colspan() > 1 { + // exclude grouped columns. + let excluded: Vec = + ((cell_key + 1)..=(cell_key + cell.colspan() - 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 = Vec::new(); + for group in groups { + for row in group { + if row.is_table_separator() { + continue; + } + + let mut row_arr = row.cells(); + for i in 0..row_arr.len() { + let cell = row_arr[i].clone(); + if cell.is_table_cell() { + let text_content = self.remove_decoration(&cell.to_php_string()); + let text_length = Helper::width(&text_content); + if text_length > 0 { + let content_columns = shirabe_php_shim::mb_str_split( + &text_content, + (text_length as f64 / cell.colspan() as f64).ceil() as i64, + ); + for (position, content) in content_columns.into_iter().enumerate() { + set_cell( + &mut row_arr, + i as i64 + position as i64, + Cell::Value(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: &[Cell], column: i64) -> i64 { + let mut cell_width = 0; + + if let Some(cell) = row.get(column as usize) { + cell_width = Helper::width(&self.remove_decoration(&cell.to_php_string())); + } + + 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 { + 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 = 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: StyleName, + ) -> anyhow::Result> { + let name = match name { + StyleName::Style(style) => return Ok(Ok(style)), + StyleName::Name(name) => name, + }; + + let styles_guard = styles().lock().unwrap(); + if let Some(style) = styles_guard.as_ref().and_then(|s| s.get(&name)) { + return Ok(Ok(style.clone())); + } + + Ok(Err(InvalidArgumentException::new(format!( + "Style \"{}\" is not defined.", + name + )))) + } + + fn formatter_is_wrappable( + _output: &std::rc::Rc>, + ) -> bool { + // PHP: $this->output->getFormatter() instanceof WrappableOutputFormatterInterface. + // The sole OutputFormatterInterface implementor in this port is OutputFormatter, which + // implements WrappableOutputFormatterInterface, so the instanceof check always holds. + true + } + + /// `setColumnMaxWidth` guarantees the formatter is a `WrappableOutputFormatterInterface`, and + /// `OutputFormatter` is the sole implementor in this port, so `instanceof` reduces to this + /// downcast. + fn format_and_wrap(&self, string: &str, width: i64) -> String { + let formatter = self.output.borrow().get_formatter(); + let mut formatter = formatter.borrow_mut(); + let formatter = formatter + .as_any_mut() + .downcast_mut::() + .expect("formatter must be a WrappableOutputFormatterInterface"); + formatter + .format_and_wrap(Some(string), width) + .unwrap() + .unwrap_or_default() + } + + /// 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: &std::rc::Rc>, + ) -> bool { + // PHP: $this->output instanceof ConsoleSectionOutput + let borrowed = output.borrow(); + (*borrowed) + .as_any() + .downcast_ref::() + .is_some() + } + + fn table_cell_options_colspan(colspan: i64) -> IndexMap { + // PHP: ['colspan' => $colspan] + let mut options = IndexMap::new(); + options.insert("colspan".to_string(), TableCellOption::Int(colspan)); + options + } + + fn table_cell_options_colspan_style( + colspan: i64, + style: Option>, + ) -> IndexMap { + // PHP: ['colspan' => $colspan, 'style' => $style] + let mut options = IndexMap::new(); + options.insert("colspan".to_string(), TableCellOption::Int(colspan)); + options.insert( + "style".to_string(), + match style { + Some(style) => TableCellOption::Style(style), + None => TableCellOption::Null, + }, + ); + options + } +} diff --git a/crates/shirabe-symfony-console/src/helper/table_cell.rs b/crates/shirabe-symfony-console/src/helper/table_cell.rs new file mode 100644 index 00000000..100872ca --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/table_cell.rs @@ -0,0 +1,99 @@ +//! ref: composer/vendor/symfony/console/Helper/TableCell.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::helper::table_cell_style::TableCellStyle; +use indexmap::IndexMap; + +/// A `TableCell` option value: an integer span, a `TableCellStyle`, or null. +#[derive(Debug, Clone)] +pub enum TableCellOption { + Int(i64), + Style(std::rc::Rc), + Null, +} + +#[derive(Debug, Clone)] +pub struct TableCell { + pub(crate) value: String, + options: IndexMap, +} + +impl TableCell { + pub fn new( + value: &str, + options: IndexMap, + ) -> Result { + let mut this_options: IndexMap = 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 = options + .keys() + .filter(|key| !this_options.contains_key(*key)) + .cloned() + .collect(); + if !diff.is_empty() { + return Err(InvalidArgumentException::new(format!( + "The TableCell does not support the following options: '{}'.", + diff.join("', '"), + ))); + } + + if let Some(style) = options.get("style") + && !matches!(style, TableCellOption::Style(_)) + && !matches!(style, TableCellOption::Null) + { + return Err(InvalidArgumentException::new( + "The style option must be an instance of \"TableCellStyle\".".to_string(), + )); + } + + 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) -> Self { + Self::new(value, options).expect("TableCell options built internally are always valid") + } + + /// 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> { + 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-symfony-console/src/helper/table_cell_style.rs b/crates/shirabe-symfony-console/src/helper/table_cell_style.rs new file mode 100644 index 00000000..6f0d6b5c --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/table_cell_style.rs @@ -0,0 +1,114 @@ +//! ref: composer/vendor/symfony/console/Helper/TableCellStyle.php + +use crate::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 { + 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, +} + +impl TableCellStyle { + pub fn new( + options: IndexMap, + ) -> Result { + let mut this_options: IndexMap = 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 = options + .keys() + .filter(|key| !this_options.contains_key(*key)) + .cloned() + .collect(); + if !diff.is_empty() { + return Err(InvalidArgumentException::new(format!( + "The TableCellStyle does not support the following options: '{}'.", + diff.join("', '"), + ))); + } + + 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::new(format!( + "Wrong align value. Value must be following: '{}'.", + align_map_keys().join("', '"), + ))); + } + } + + for (key, value) in options { + this_options.insert(key, value); + } + + Ok(Self { + options: this_options, + }) + } + + pub fn get_options(&self) -> IndexMap { + self.options.clone() + } + + /// Gets options we need for tag for example fg, bg. + pub fn get_tag_options(&self) -> IndexMap { + let mut result: IndexMap = 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 { + match &self.get_options()["cellFormat"] { + shirabe_php_shim::PhpMixed::String(format) => Some(format.clone()), + _ => None, + } + } +} diff --git a/crates/shirabe-symfony-console/src/helper/table_rows.rs b/crates/shirabe-symfony-console/src/helper/table_rows.rs new file mode 100644 index 00000000..57db5ee8 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/table_rows.rs @@ -0,0 +1,45 @@ +//! ref: composer/vendor/symfony/console/Helper/TableRows.php + +use crate::helper::table::Row; + +/// @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>, +} + +impl TableRows { + pub fn from_row_groups(row_groups: Vec>) -> Self { + Self { row_groups } + } + + pub fn get_iterator(&self) -> std::slice::Iter<'_, Vec> { + self.row_groups.iter() + } + + pub fn into_row_groups(self) -> Vec> { + self.row_groups + } +} + +impl<'a> IntoIterator for &'a TableRows { + type Item = &'a Vec; + type IntoIter = std::slice::Iter<'a, Vec>; + + fn into_iter(self) -> Self::IntoIter { + self.row_groups.iter() + } +} + +impl IntoIterator for TableRows { + type Item = Vec; + type IntoIter = std::vec::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + self.row_groups.into_iter() + } +} diff --git a/crates/shirabe-symfony-console/src/helper/table_separator.rs b/crates/shirabe-symfony-console/src/helper/table_separator.rs new file mode 100644 index 00000000..99862848 --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/table_separator.rs @@ -0,0 +1,46 @@ +//! ref: composer/vendor/symfony/console/Helper/TableSeparator.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::helper::table_cell::{TableCell, TableCellOption}; +use indexmap::IndexMap; + +/// Marks a row as being a separator. +#[derive(Debug, Clone)] +pub struct TableSeparator { + inner: TableCell, +} + +impl TableSeparator { + pub fn new() -> Self { + Self::new1(IndexMap::new()).expect("TableSeparator default options are always valid") + } + + pub fn new1( + options: IndexMap, + ) -> Result { + Ok(Self { + inner: TableCell::new("", options)?, + }) + } + + // PHP `TableSeparator extends TableCell`, so these inherited accessors remain available. + pub fn get_colspan(&self) -> i64 { + self.inner.get_colspan() + } + + pub fn get_rowspan(&self) -> i64 { + self.inner.get_rowspan() + } + + pub fn get_style( + &self, + ) -> Option> { + self.inner.get_style() + } +} + +impl Default for TableSeparator { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/shirabe-symfony-console/src/helper/table_style.rs b/crates/shirabe-symfony-console/src/helper/table_style.rs new file mode 100644 index 00000000..9566482f --- /dev/null +++ b/crates/shirabe-symfony-console/src/helper/table_style.rs @@ -0,0 +1,289 @@ +//! ref: composer/vendor/symfony/console/Helper/TableStyle.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::logic_exception::LogicException; + +/// Defines the styles for a Table. +#[derive(Debug, Clone)] +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: " %s ".to_string(), + footer_title_format: " %s ".to_string(), + cell_header_format: "%s".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> { + if padding_char.is_empty() { + return Ok(Err(LogicException::new( + "The padding char must not be empty.".to_string(), + ))); + } + + 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, + ) -> &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, + ) -> &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 { + 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, + top_mid_bottom: Option, + top_right_bottom: Option, + ) -> &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 { + 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> { + 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::new("Invalid padding type. Expected one of (STR_PAD_LEFT, STR_PAD_RIGHT, STR_PAD_BOTH)." + .to_string()))); + } + + 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-symfony-console/src/input.rs b/crates/shirabe-symfony-console/src/input.rs new file mode 100644 index 00000000..d4813b44 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input.rs @@ -0,0 +1,21 @@ +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::*; +pub use streamable_input_interface::*; +pub use string_input::*; diff --git a/crates/shirabe-symfony-console/src/input/argv_input.rs b/crates/shirabe-symfony-console/src/input/argv_input.rs new file mode 100644 index 00000000..d8619df9 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/argv_input.rs @@ -0,0 +1,657 @@ +//! ref: composer/vendor/symfony/console/Input/ArgvInput.php + +use crate::exception::runtime_exception::RuntimeException; +use crate::input::input::Input; +use crate::input::input_definition::InputDefinition; +use crate::input::input_interface::InputInterface; +use crate::input::streamable_input_interface::StreamableInputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::{PhpMixed, php_regex}; + +/// ArgvInput represents an input coming from the CLI arguments. +/// +/// Usage: +/// +/// ```php +/// $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: +/// +/// ```php +/// $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, Clone)] +pub struct ArgvInput { + pub(crate) inner: Input, + tokens: Vec, + parsed: Vec, +} + +impl ArgvInput { + pub fn new( + argv: Option>, + definition: Option, + ) -> anyhow::Result { + // $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) { + self.tokens = tokens; + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.base_bind(definition, ArgvInput::parse_token) + } + + /// Shared body of `bind` (PHP `Input::bind`). PHP's `$this->parse()` ends in + /// `$this->parseToken()`, which late-binds to `CompletionInput::parseToken`; `parseToken` + /// is protected and not part of any trait, so the concrete implementation is threaded in + /// as a callback taking the embedded ArgvInput. + pub(crate) fn base_bind( + &mut self, + definition: &InputDefinition, + parse_token: impl FnMut(&mut ArgvInput, &str, bool) -> anyhow::Result, + ) -> anyhow::Result<()> { + self.inner.arguments = IndexMap::new(); + self.inner.options = IndexMap::new(); + self.inner.definition = definition.clone(); + + self.base_parse(parse_token)?; + + Ok(()) + } + + /// Shared body of `parse`; see `base_bind` for why `parse_token` is a parameter. + fn base_parse( + &mut self, + mut parse_token: impl FnMut(&mut ArgvInput, &str, bool) -> anyhow::Result, + ) -> 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 = parse_token(self, &token, parse_options)?; + } + Ok(()) + } + + pub(crate) fn parse_token(&mut self, token: &str, parse_options: bool) -> anyhow::Result { + if parse_options && token.is_empty() { + 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, + Some(encoding) => { + shirabe_php_shim::mb_substr(name, i, Some(1), Some(&encoding)) + } + }; + return Err(RuntimeException::new(format!( + "The \"-{}\" option does not exist.", + bad + )) + .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(), value)?; + + break; + } else { + self.add_long_option(option.get_name(), 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.is_empty() { + 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![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(PhpMixed::String(token.to_string())); + } + + // unexpected argument + } else { + let mut all = self.inner.definition.get_arguments().clone(); + let mut symfony_command_name: Option = 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 = self.inner.arguments.get("command").cloned(); + all.shift_remove(key); + } + } + + let message = if !all.is_empty() { + let names: Vec = all.keys().cloned().collect(); + match &symfony_command_name { + Some(symfony_command_name) + if !matches!(symfony_command_name, PhpMixed::Null) => + { + format!( + "Too many arguments to \"{}\" command, expected arguments \"{}\".", + symfony_command_name.clone(), + shirabe_php_shim::implode("\" \"", &names), + ) + } + _ => format!( + "Too many arguments, expected arguments \"{}\".", + shirabe_php_shim::implode("\" \"", &names), + ), + } + } else if symfony_command_name + .as_ref() + .map(|n| !matches!(n, PhpMixed::Null)) + .unwrap_or(false) + { + format!( + "No arguments expected for \"{}\" command, got \"{}\".", + symfony_command_name.unwrap(), + token, + ) + } else { + format!("No arguments expected, got \"{}\".", token) + }; + + return Err(RuntimeException::new(message).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::new(format!( + "The \"-{}\" option does not exist.", + shortcut + )) + .into()); + } + + self.add_long_option( + self.inner + .definition + .get_option_for_shortcut(shortcut)? + .get_name(), + 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::new(format!( + "The \"--{}\" option does not exist.", + name + )) + .into()); + } + + let option_name = self.inner.definition.negation_to_name(name)?; + if !matches!(value, PhpMixed::Null) { + return Err(RuntimeException::new(format!( + "The \"--{}\" option does not accept a value.", + name + )) + .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::new(format!( + "The \"--{}\" option does not accept a value.", + name + )) + .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::new(format!( + "The \"--{}\" option requires a value.", + name + )) + .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(value); + } + _ => { + self.inner + .options + .insert(name.to_string(), PhpMixed::List(vec![value])); + } + } + } else { + self.inner.options.insert(name.to_string(), value); + } + + Ok(()) + } + + pub fn get_first_argument(&self) -> Option { + 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) + && let Ok(resolved) = self.inner.definition.shortcut_to_name(&name) + { + name = resolved; + } + if let Some(option_value) = self.inner.options.get(&name) + && 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. +impl std::fmt::Display for ArgvInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let tokens: Vec = self + .tokens + .iter() + .map(|token| { + let mut r#match: Vec> = Vec::new(); + if shirabe_php_shim::preg_match(php_regex!("{^(-[^=]+=)(.+)}"), token, &mut r#match) + { + return format!( + "{}{}", + r#match[1].as_deref().unwrap_or(""), + self.inner.escape_token(r#match[2].as_deref().unwrap_or("")) + ); + } + + if !token.is_empty() && token.as_bytes()[0] != b'-' { + return self.inner.escape_token(token); + } + + token.clone() + }) + .collect(); + + write!(f, "{}", shirabe_php_shim::implode(" ", &tokens)) + } +} + +impl InputInterface for ArgvInput { + fn dup(&self) -> std::rc::Rc> { + std::rc::Rc::new(std::cell::RefCell::new(self.clone())) + } + + fn get_first_argument(&self) -> Option { + 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 { + self.inner.get_arguments() + } + + fn get_argument(&self, name: &str) -> anyhow::Result { + 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 { + self.inner.get_options() + } + + fn get_option(&self, name: &str) -> anyhow::Result { + 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) + } + + fn __to_string(&self) -> String { + self.to_string() + } + + fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> { + Some(self) + } + + fn as_streamable_mut(&mut self) -> Option<&mut dyn StreamableInputInterface> { + Some(self) + } +} + +impl StreamableInputInterface for ArgvInput { + fn set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.inner.set_stream(stream) + } + + fn get_stream(&self) -> Option { + self.inner.get_stream() + } +} + +/// PHP `(array) $values` cast: a string becomes a single-element array. +fn to_array(values: PhpMixed) -> Vec { + 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-symfony-console/src/input/array_input.rs b/crates/shirabe-symfony-console/src/input/array_input.rs new file mode 100644 index 00000000..fa1d4540 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/array_input.rs @@ -0,0 +1,386 @@ +//! ref: composer/vendor/symfony/console/Input/ArrayInput.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::invalid_option_exception::InvalidOptionException; +use crate::input::input::Input; +use crate::input::input_definition::InputDefinition; +use crate::input::input_interface::InputInterface; +use crate::input::streamable_input_interface::StreamableInputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// ArrayInput represents an input provided as an array. +/// +/// Usage: +/// +/// ```php +/// $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, Clone)] +pub struct ArrayInput { + pub(crate) inner: Input, + parameters: Vec<(PhpMixed, PhpMixed)>, +} + +impl ArrayInput { + pub fn new( + parameters: Vec<(PhpMixed, PhpMixed)>, + definition: Option, + ) -> anyhow::Result { + 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 { + for (param, value) in &self.parameters { + // $param && \is_string($param) && '-' === $param[0] + if let PhpMixed::String(param) = param + && !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 + } + + 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::new(format!( + "The \"-{}\" option does not exist.", + shortcut + )) + .into()); + } + + self.add_long_option( + self.inner + .definition + .get_option_for_shortcut(shortcut)? + .get_name(), + 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::new(format!( + "The \"--{}\" option does not exist.", + name + )) + .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::new(format!( + "The \"--{}\" option requires a value.", + name + )) + .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::new(format!( + "The \"{}\" argument does not exist.", + name.clone() + )) + .into()); + } + + self.inner + .arguments + .insert(shirabe_php_shim::php_to_string(name), value); + + Ok(()) + } +} + +/// Returns a stringified representation of the args passed to the command. +impl std::fmt::Display for ArrayInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut params: Vec = 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.is_empty() { + 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.is_empty() { + format!("{}{}", glue, self.inner.escape_token(&val)) + } else { + String::new() + } + )); + } + } else if let PhpMixed::List(list) = val { + let escaped: Vec = 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)), + ); + } + } + + write!(f, "{}", shirabe_php_shim::implode(" ", ¶ms)) + } +} + +impl InputInterface for ArrayInput { + fn dup(&self) -> std::rc::Rc> { + std::rc::Rc::new(std::cell::RefCell::new(self.clone())) + } + + fn get_first_argument(&self) -> Option { + ArrayInput::get_first_argument(self).map(|v| shirabe_php_shim::php_to_string(&v)) + } + + 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: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + ArrayInput::get_parameter_option(self, values, default, only_params) + } + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + ArrayInput::bind(self, definition) + } + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() + } + + fn get_arguments(&self) -> IndexMap { + self.inner.get_arguments() + } + + fn get_argument(&self, name: &str) -> anyhow::Result { + 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 { + self.inner.get_options() + } + + fn get_option(&self, name: &str) -> anyhow::Result { + 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) + } + + fn __to_string(&self) -> String { + self.to_string() + } + + fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> { + Some(self) + } +} + +impl StreamableInputInterface for ArrayInput { + fn set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.inner.set_stream(stream) + } + + fn get_stream(&self) -> Option { + self.inner.get_stream() + } +} + +/// PHP `(array) $values` cast: a string becomes a single-element array. +fn to_array(values: PhpMixed) -> Vec { + match values { + PhpMixed::List(list) => list.into_iter().collect(), + PhpMixed::Array(array) => array.into_iter().map(|(_, v)| v).collect(), + PhpMixed::Null => vec![], + other => vec![other], + } +} diff --git a/crates/shirabe-symfony-console/src/input/input.rs b/crates/shirabe-symfony-console/src/input/input.rs new file mode 100644 index 00000000..15190665 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/input.rs @@ -0,0 +1,225 @@ +//! ref: composer/vendor/symfony/console/Input/Input.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::runtime_exception::RuntimeException; +use crate::input::input_definition::InputDefinition; +use indexmap::IndexMap; +use shirabe_php_shim::{PhpMixed, PhpResource, php_regex}; + +/// 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, Clone)] +pub struct Input { + pub(crate) definition: InputDefinition, + pub(crate) stream: Option, + pub(crate) options: IndexMap, + pub(crate) arguments: IndexMap, + pub(crate) interactive: bool, +} + +impl Input { + pub fn new(definition: Option) -> anyhow::Result { + let mut input = Input { + definition: InputDefinition::new(vec![])?, + stream: None, + 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 = 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.is_empty() { + return Err(RuntimeException::new(format!( + "Not enough arguments (missing: \"{}\").", + shirabe_php_shim::implode(", ", &missing_arguments), + )) + .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 { + shirabe_php_shim::array_merge_map( + self.definition.get_argument_defaults(), + self.arguments.clone(), + ) + } + + pub fn get_argument(&self, name: &str) -> anyhow::Result { + if !self + .definition + .has_argument(&PhpMixed::String(name.to_string())) + { + return Err(InvalidArgumentException::new(format!( + "The \"{}\" argument does not exist.", + name + )) + .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::new(format!( + "The \"{}\" argument does not exist.", + name + )) + .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 { + shirabe_php_shim::array_merge_map( + self.definition.get_option_defaults(), + self.options.clone(), + ) + } + + pub fn get_option(&self, name: &str) -> anyhow::Result { + 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::new(format!( + "The \"{}\" option does not exist.", + name + )) + .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::new(format!( + "The \"{}\" option does not exist.", + name + )) + .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> = vec![]; + if shirabe_php_shim::preg_match(php_regex!("{^[\\w-]+$}"), token, &mut matches) { + token.to_string() + } else { + shirabe_php_shim::escapeshellarg(token) + } + } + + pub fn set_stream(&mut self, stream: PhpResource) { + self.stream = Some(stream); + } + + pub fn get_stream(&self) -> Option { + self.stream.clone() + } +} diff --git a/crates/shirabe-symfony-console/src/input/input_argument.rs b/crates/shirabe-symfony-console/src/input/input_argument.rs new file mode 100644 index 00000000..ebc8e223 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/input_argument.rs @@ -0,0 +1,96 @@ +//! ref: composer/vendor/symfony/console/Input/InputArgument.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::logic_exception::LogicException; +use shirabe_php_shim::PhpMixed; + +#[derive(Debug, Clone)] +pub struct InputArgument { + name: String, + mode: i64, + default: PhpMixed, + description: String, +} + +impl InputArgument { + pub const REQUIRED: i64 = 1; + pub const OPTIONAL: i64 = 2; + pub const IS_ARRAY: i64 = 4; + + pub fn new( + name: String, + mode: Option, + description: String, + default: PhpMixed, + ) -> anyhow::Result { + let mode = match mode { + None => Self::OPTIONAL, + Some(m) if !(1..=7).contains(&m) => { + return Err(InvalidArgumentException::new(format!( + "Argument mode \"{}\" is not valid.", + m + )) + .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) -> &str { + &self.name + } + + pub fn is_required(&self) -> bool { + Self::REQUIRED == (Self::REQUIRED & self.mode) + } + + pub fn is_array(&self) -> bool { + 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::new( + "Cannot set a default value except for InputArgument::OPTIONAL mode.".to_string(), + ) + .into()); + } + + let default = if self.is_array() { + match default { + PhpMixed::Null => PhpMixed::List(vec![]), + PhpMixed::List(_) => default, + _ => { + return Err(LogicException::new( + "A default value for an array argument must be an array.".to_string(), + ) + .into()); + } + } + } else { + default + }; + + self.default = default; + Ok(()) + } + + pub fn get_default(&self) -> &PhpMixed { + &self.default + } + + pub fn get_description(&self) -> &str { + &self.description + } +} diff --git a/crates/shirabe-symfony-console/src/input/input_aware_interface.rs b/crates/shirabe-symfony-console/src/input/input_aware_interface.rs new file mode 100644 index 00000000..1638b6c7 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/input_aware_interface.rs @@ -0,0 +1,7 @@ +//! ref: composer/vendor/symfony/console/Input/InputAwareInterface.php + +use crate::input::input_interface::InputInterface; + +pub trait InputAwareInterface { + fn set_input(&mut self, input: Box); +} diff --git a/crates/shirabe-symfony-console/src/input/input_definition.rs b/crates/shirabe-symfony-console/src/input/input_definition.rs new file mode 100644 index 00000000..96f9ce9a --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/input_definition.rs @@ -0,0 +1,450 @@ +//! ref: composer/vendor/symfony/console/Input/InputDefinition.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::logic_exception::LogicException; +use crate::input::input_argument::InputArgument; +use crate::input::input_option::InputOption; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// 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>, + required_count: i64, + last_array_argument: Option>, + last_optional_argument: Option>, + options: IndexMap>, + negations: IndexMap, + shortcuts: IndexMap, +} + +/// A definition entry is either an InputArgument or an InputOption. +#[derive(Debug)] +pub enum DefinitionItem { + InputArgument(InputArgument), + InputOption(InputOption), +} + +impl InputDefinition { + pub fn new(definition: Vec) -> anyhow::Result { + 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) + } + + /// Builds an option-only definition that shares the given options by + /// reference, mirroring `new InputDefinition($definition->getOptions())`. + /// `InputOption` is not `Clone` and lives behind `Rc`, so the options are + /// reused rather than reconstructed by value. + pub fn from_options(options: Vec>) -> anyhow::Result { + 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(), + }; + for option in options { + input_definition.add_option_rc(option)?; + } + Ok(input_definition) + } + + /// Sets the definition of the input. + pub fn set_definition(&mut self, definition: Vec) -> 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) -> 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>) -> 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 = std::rc::Rc::new(argument); + + if self.arguments.contains_key(argument.get_name()) { + return Err(LogicException::new(format!( + "An argument with name \"{}\" already exists.", + argument.get_name(), + )) + .into()); + } + + if let Some(last_array_argument) = &self.last_array_argument { + return Err(LogicException::new(format!( + "Cannot add a required argument \"{}\" after an array argument \"{}\".", + argument.get_name(), + last_array_argument.get_name(), + )) + .into()); + } + + if argument.is_required() + && let Some(last_optional_argument) = &self.last_optional_argument + { + return Err(LogicException::new(format!( + "Cannot add a required argument \"{}\" after an optional one \"{}\".", + argument.get_name(), + last_optional_argument.get_name(), + )) + .into()); + } + + if argument.is_array() { + self.last_array_argument = Some(std::rc::Rc::clone(&argument)); + } + + if argument.is_required() { + self.required_count += 1; + } else { + self.last_optional_argument = Some(std::rc::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> { + if !self.has_argument(name) { + return Err(InvalidArgumentException::new(format!( + "The \"{}\" argument does not exist.", + name.clone() + )) + .into()); + } + + match name { + PhpMixed::Int(index) => { + let arguments: Vec> = + self.arguments.values().cloned().collect(); + Ok(std::rc::Rc::clone(&arguments[*index as usize])) + } + _ => { + let key = shirabe_php_shim::php_to_string(name); + Ok(std::rc::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> = + 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> { + &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 { + 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) -> 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) -> anyhow::Result<()> { + for option in options { + self.add_option(option)?; + } + Ok(()) + } + + pub fn add_option(&mut self, option: InputOption) -> anyhow::Result<()> { + self.add_option_rc(std::rc::Rc::new(option)) + } + + /// Adds an option that is already shared behind `Rc`, mirroring PHP passing + /// `InputOption` objects by reference. + pub fn add_option_rc(&mut self, option: std::rc::Rc) -> anyhow::Result<()> { + if let Some(existing) = self.options.get(option.get_name()) + && !option.equals(existing) + { + return Err(LogicException::new(format!( + "An option named \"{}\" already exists.", + option.get_name() + )) + .into()); + } + if self.negations.contains_key(option.get_name()) { + return Err(LogicException::new(format!( + "An option named \"{}\" already exists.", + option.get_name() + )) + .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) + && !option.equals(&self.options[existing_name]) + { + return Err(LogicException::new(format!( + "An option with shortcut \"{}\" already exists.", + shortcut.clone(), + )) + .into()); + } + } + } + + self.options + .insert(option.get_name().to_string(), std::rc::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::new(format!( + "An option named \"{}\" already exists.", + negated_name + )) + .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> { + if !self.has_option(name) { + return Err(InvalidArgumentException::new(format!( + "The \"--{}\" option does not exist.", + name + )) + .into()); + } + + Ok(std::rc::Rc::clone(&self.options[name])) + } + + /// 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) + } + + /// Gets the array of InputOption objects. + pub fn get_options(&self) -> &IndexMap> { + &self.options + } + + /// Returns true if an InputOption object exists by shortcut. + pub fn has_shortcut(&self, name: &str) -> bool { + self.shortcuts.contains_key(name) + } + + /// Returns true if an InputOption object exists by negated name. + pub fn has_negation(&self, name: &str) -> bool { + self.negations.contains_key(name) + } + + /// Gets an InputOption by shortcut. + pub fn get_option_for_shortcut( + &self, + shortcut: &str, + ) -> anyhow::Result> { + self.get_option(&self.shortcut_to_name(shortcut)?) + } + + pub fn get_option_defaults(&self) -> IndexMap { + 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 { + match self.shortcuts.get(shortcut) { + None => Err(InvalidArgumentException::new(format!( + "The \"-{}\" option does not exist.", + shortcut + )) + .into()), + Some(name) => Ok(name.clone()), + } + } + + /// Returns the InputOption name given a negation. + pub fn negation_to_name(&self, negation: &str) -> anyhow::Result { + match self.negations.get(negation) { + None => Err(InvalidArgumentException::new(format!( + "The \"--{}\" option does not exist.", + negation + )) + .into()), + Some(name) => Ok(name.clone()), + } + } + + /// Gets the synopsis. + pub fn get_synopsis(&self, short: bool) -> String { + let mut elements: Vec = 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 = format!( + " {}{}{}", + if option.is_value_optional() { + "[".to_string() + } else { + String::new() + }, + shirabe_php_shim::strtoupper(option.get_name()), + if option.is_value_optional() { + "]".to_string() + } else { + String::new() + }, + ); + } + + let shortcut = match option.get_shortcut() { + Some(shortcut) => { + format!("-{}|", shortcut) + } + None => String::new(), + }; + let negation = if option.is_negatable() { + format!("|--no-{}", option.get_name()) + } else { + String::new() + }; + elements.push(format!( + "[{}--{}{}{}]", + shortcut, + option.get_name(), + value, + negation, + )); + } + } + + if !elements.is_empty() && !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-symfony-console/src/input/input_interface.rs b/crates/shirabe-symfony-console/src/input/input_interface.rs new file mode 100644 index 00000000..b3cd2eef --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/input_interface.rs @@ -0,0 +1,60 @@ +//! ref: composer/vendor/symfony/console/Input/InputInterface.php + +use crate::input::input_definition::InputDefinition; +use crate::input::streamable_input_interface::StreamableInputInterface; +use shirabe_php_shim::PhpMixed; + +pub trait InputInterface: std::fmt::Debug + shirabe_php_shim::AsAny { + /// Models PHP's `clone` operatior. + fn dup(&self) -> std::rc::Rc>; + + fn get_first_argument(&self) -> Option; + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool; + + fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed; + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()>; + + fn validate(&mut self) -> anyhow::Result<()>; + + fn get_arguments(&self) -> indexmap::IndexMap; + + fn get_argument(&self, name: &str) -> anyhow::Result; + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()>; + + fn has_argument(&self, name: &str) -> bool; + + fn get_options(&self) -> indexmap::IndexMap; + + fn get_option(&self, name: &str) -> anyhow::Result; + + 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); + + /// PHP's `(string) $input` (the `__toString` magic method every Symfony input implements); + /// implementors forward to their `Display` impl. + fn __to_string(&self) -> String; + + /// Models PHP's `$input instanceof StreamableInputInterface` check. Streamable inputs override + /// this to return `Some(self)`; everything else falls back to `None`. + fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> { + None + } + + /// Mutable counterpart of `as_streamable`, needed to call `set_stream`/`set_interactive`. + fn as_streamable_mut(&mut self) -> Option<&mut dyn StreamableInputInterface> { + None + } +} diff --git a/crates/shirabe-symfony-console/src/input/input_option.rs b/crates/shirabe-symfony-console/src/input/input_option.rs new file mode 100644 index 00000000..7312e552 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/input_option.rs @@ -0,0 +1,193 @@ +//! ref: composer/vendor/symfony/console/Input/InputOption.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::logic_exception::LogicException; +use shirabe_php_shim::{PhpMixed, php_regex}; + +#[derive(Debug, Clone)] +pub struct InputOption { + name: String, + shortcut: Option, + mode: i64, + default: PhpMixed, + description: String, +} + +impl InputOption { + pub const VALUE_NONE: i64 = 1; + pub const VALUE_REQUIRED: i64 = 2; + pub const VALUE_OPTIONAL: i64 = 4; + pub const VALUE_IS_ARRAY: i64 = 8; + pub const VALUE_NEGATABLE: i64 = 16; + + pub fn new( + name: &str, + shortcut: PhpMixed, + mode: Option, + description: String, + default: PhpMixed, + ) -> anyhow::Result { + let name = if let Some(stripped) = name.strip_prefix("--") { + stripped.to_string() + } else { + name.to_string() + }; + + if name.is_empty() { + return Err(InvalidArgumentException::new( + "An option name cannot be empty.".to_string(), + ) + .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 = arr + .iter() + .filter_map(|v| { + if let PhpMixed::String(s) = v { + 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 !(1..(Self::VALUE_NEGATABLE << 1)).contains(&m) => { + return Err(InvalidArgumentException::new(format!( + "Option mode \"{}\" is not valid.", + m + )) + .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::new("Impossible to have an option mode VALUE_IS_ARRAY if the option does not accept a value.".to_string()) + .into()); + } + if option.is_negatable() && option.accept_value() { + return Err(InvalidArgumentException::new("Impossible to have an option mode VALUE_NEGATABLE if the option also accepts a value.".to_string()) + .into()); + } + + option.set_default(default)?; + + Ok(option) + } + + fn normalize_shortcut(s: String) -> anyhow::Result> { + let stripped = shirabe_php_shim::ltrim(&s, Some("-")); + let parts = shirabe_php_shim::preg_split(php_regex!(r"{(\|)-?}"), &stripped); + let filtered: Vec = + 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::new( + "An option shortcut cannot be empty.".to_string(), + ) + .into()); + } + Ok(Some(result)) + } + + pub fn get_shortcut(&self) -> Option<&str> { + self.shortcut.as_deref() + } + + pub fn get_name(&self) -> &str { + &self.name + } + + pub fn accept_value(&self) -> bool { + self.is_value_required() || self.is_value_optional() + } + + pub fn is_value_required(&self) -> bool { + Self::VALUE_REQUIRED == (Self::VALUE_REQUIRED & self.mode) + } + + pub fn is_value_optional(&self) -> bool { + Self::VALUE_OPTIONAL == (Self::VALUE_OPTIONAL & self.mode) + } + + pub fn is_array(&self) -> bool { + 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::new( + "Cannot set a default value when using InputOption::VALUE_NONE mode.".to_string(), + ) + .into()); + } + + let default = if self.is_array() { + match default { + PhpMixed::Null => PhpMixed::List(vec![]), + // PHP `is_array()` accepts both list-style and associative arrays. + PhpMixed::List(_) | PhpMixed::Array(_) => default, + _ => { + return Err(LogicException::new( + "A default value for an array option must be an array.".to_string(), + ) + .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 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-symfony-console/src/input/streamable_input_interface.rs b/crates/shirabe-symfony-console/src/input/streamable_input_interface.rs new file mode 100644 index 00000000..559b34a0 --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/streamable_input_interface.rs @@ -0,0 +1,10 @@ +//! ref: composer/vendor/symfony/console/Input/StreamableInputInterface.php + +use crate::input::input_interface::InputInterface; +use shirabe_php_shim::PhpResource; + +pub trait StreamableInputInterface: InputInterface { + fn set_stream(&mut self, stream: PhpResource); + + fn get_stream(&self) -> Option; +} diff --git a/crates/shirabe-symfony-console/src/input/string_input.rs b/crates/shirabe-symfony-console/src/input/string_input.rs new file mode 100644 index 00000000..720e2bef --- /dev/null +++ b/crates/shirabe-symfony-console/src/input/string_input.rs @@ -0,0 +1,243 @@ +//! ref: composer/vendor/symfony/console/Input/StringInput.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::input::argv_input::ArgvInput; +use crate::input::input_definition::InputDefinition; +use crate::input::input_interface::InputInterface; +use crate::input::streamable_input_interface::StreamableInputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::{CaptureKey, PhpMixed, php_regex}; + +/// StringInput represents an input provided as a string. +/// +/// Usage: +/// +/// ```php +/// $input = new StringInput('foo --bar="foobar"'); +/// ``` +#[derive(Debug, Clone)] +pub struct StringInput { + pub(crate) inner: ArgvInput, +} + +impl StringInput { + pub const REGEX_STRING: &'static str = r#"([^\s]+?)(?:\s|(? anyhow::Result { + // 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> { + let bytes = input.as_bytes(); + let mut tokens: Vec = vec![]; + let length = shirabe_php_shim::strlen(input); + let mut cursor: i64 = 0; + let mut token: Option = 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: IndexMap> = IndexMap::new(); + if shirabe_php_shim::preg_match2( + php_regex!(r"/\s+/A"), + input, + &mut m, + 0, + cursor as usize, + ) { + if token.is_some() { + tokens.push(token.take().unwrap()); + } + cursor += + shirabe_php_shim::strlen(m[&CaptureKey::ByIndex(0)].as_deref().unwrap_or("")); + } else if shirabe_php_shim::preg_match2( + format!(r#"/([^="'\s]+?)(=?)({}+)/A"#, Self::REGEX_QUOTED_STRING), + input, + &mut m, + 0, + cursor as usize, + ) { + let inner = shirabe_php_shim::substr( + m[&CaptureKey::ByIndex(3)].as_deref().unwrap_or(""), + 1, + Some(-1), + ); + let replaced = + shirabe_php_shim::str_replace_arr(&["\"'", "'\"", "''", "\"\""], "", &inner); + token = Some(format!( + "{}{}{}{}", + token.unwrap_or_default(), + m[&CaptureKey::ByIndex(1)].as_deref().unwrap_or(""), + m[&CaptureKey::ByIndex(2)].as_deref().unwrap_or(""), + shirabe_php_shim::stripcslashes(&replaced) + )); + cursor += + shirabe_php_shim::strlen(m[&CaptureKey::ByIndex(0)].as_deref().unwrap_or("")); + } else if shirabe_php_shim::preg_match2( + format!(r"/{}/A", Self::REGEX_QUOTED_STRING), + input, + &mut m, + 0, + cursor as usize, + ) { + token = Some(format!( + "{}{}", + token.unwrap_or_default(), + shirabe_php_shim::stripcslashes(&shirabe_php_shim::substr( + m[&CaptureKey::ByIndex(0)].as_deref().unwrap_or(""), + 1, + Some(-1) + )) + )); + cursor += + shirabe_php_shim::strlen(m[&CaptureKey::ByIndex(0)].as_deref().unwrap_or("")); + } else if shirabe_php_shim::preg_match2( + format!(r"/{}/A", Self::REGEX_UNQUOTED_STRING), + input, + &mut m, + 0, + cursor as usize, + ) { + token = Some(format!( + "{}{}", + token.unwrap_or_default(), + m[&CaptureKey::ByIndex(1)].as_deref().unwrap_or("") + )); + cursor += + shirabe_php_shim::strlen(m[&CaptureKey::ByIndex(0)].as_deref().unwrap_or("")); + } else { + // should never happen + return Err(InvalidArgumentException::new(format!( + "Unable to parse input near \"... {} ...\".", + shirabe_php_shim::substr(input, cursor, Some(10)), + )) + .into()); + } + } + + if let Some(token) = token { + tokens.push(token); + } + + Ok(tokens) + } +} + +impl std::fmt::Display for StringInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.inner.fmt(f) + } +} + +impl InputInterface for StringInput { + fn dup(&self) -> std::rc::Rc> { + std::rc::Rc::new(std::cell::RefCell::new(self.clone())) + } + + fn get_first_argument(&self) -> Option { + self.inner.get_first_argument() + } + + 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: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + InputInterface::get_parameter_option(&self.inner, values, default, only_params) + } + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + InputInterface::bind(&mut self.inner, definition) + } + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() + } + + fn get_arguments(&self) -> IndexMap { + InputInterface::get_arguments(&self.inner) + } + + fn get_argument(&self, name: &str) -> anyhow::Result { + 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 { + InputInterface::get_options(&self.inner) + } + + fn get_option(&self, name: &str) -> anyhow::Result { + 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) + } + + fn __to_string(&self) -> String { + self.to_string() + } + + fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> { + Some(self) + } + + fn as_streamable_mut(&mut self) -> Option<&mut dyn StreamableInputInterface> { + Some(self) + } +} + +impl StreamableInputInterface for StringInput { + fn set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.inner.set_stream(stream) + } + + fn get_stream(&self) -> Option { + self.inner.get_stream() + } +} diff --git a/crates/shirabe-symfony-console/src/lib.rs b/crates/shirabe-symfony-console/src/lib.rs new file mode 100644 index 00000000..814e53a3 --- /dev/null +++ b/crates/shirabe-symfony-console/src/lib.rs @@ -0,0 +1,36 @@ +pub mod application; +pub mod attribute; +pub mod color; +pub mod command; +pub mod command_loader; +pub mod completion; +pub mod cursor; +pub mod descriptor; +pub mod exception; +pub mod formatter; +pub mod helper; +pub mod input; +pub mod output; +pub mod question; +pub mod signal_registry; +pub mod style; +pub mod terminal; +pub mod tester; + +pub use application::*; +pub use attribute::*; +pub use color::*; +pub use command::*; +pub use command_loader::*; +pub use completion::*; +pub use cursor::*; +pub use descriptor::*; +pub use exception::*; +pub use formatter::*; +pub use helper::*; +pub use input::*; +pub use output::*; +pub use question::*; +pub use signal_registry::*; +pub use style::*; +pub use terminal::*; diff --git a/crates/shirabe-symfony-console/src/output.rs b/crates/shirabe-symfony-console/src/output.rs new file mode 100644 index 00000000..23a8e15a --- /dev/null +++ b/crates/shirabe-symfony-console/src/output.rs @@ -0,0 +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-symfony-console/src/output/buffered_output.rs b/crates/shirabe-symfony-console/src/output/buffered_output.rs new file mode 100644 index 00000000..147f837f --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/buffered_output.rs @@ -0,0 +1,84 @@ +//! ref: composer/vendor/symfony/console/Output/BufferedOutput.php + +use crate::formatter::OutputFormatterInterface; +use crate::output::OutputInterface; +use crate::output::output::{DoWrite, Output}; + +#[derive(Debug)] +pub struct BufferedOutput { + inner: Output, + buffer: std::cell::RefCell, +} + +impl BufferedOutput { + pub fn new( + verbosity: Option, + decorated: bool, + formatter: Option>>, + ) -> 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>, + ) { + self.inner.set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc> { + self.inner.get_formatter() + } +} diff --git a/crates/shirabe-symfony-console/src/output/console_output.rs b/crates/shirabe-symfony-console/src/output/console_output.rs new file mode 100644 index 00000000..5028eaf7 --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/console_output.rs @@ -0,0 +1,210 @@ +//! ref: composer/vendor/symfony/console/Output/ConsoleOutput.php + +use crate::formatter::OutputFormatterInterface; +use crate::output::ConsoleOutputInterface; +use crate::output::OutputInterface; +use crate::output::console_section_output::ConsoleSectionOutput; +use crate::output::output_interface::VERBOSITY_NORMAL; +use crate::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. +/// +/// ```php +/// $output = new ConsoleOutput(); +/// ``` +/// +/// This is equivalent to: +/// +/// ```php +/// $output = new StreamOutput(fopen('php://stdout', 'w')); +/// $stdErr = new StreamOutput(fopen('php://stderr', 'w')); +/// ``` +#[derive(Debug)] +pub struct ConsoleOutput { + inner: StreamOutput, + stderr: std::cell::RefCell>>, + console_section_outputs: + std::rc::Rc>>>>, +} + +impl ConsoleOutput { + /// `$verbosity` defaults to `self::VERBOSITY_NORMAL`; pass `None` to use it. + pub fn new( + verbosity: Option, + decorated: Option, + formatter: Option>>, + ) -> anyhow::Result { + 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) + } + + /// Returns true if current environment supports writing console output to + /// STDOUT. + fn has_stdout_support() -> bool { + !Self::is_running_os400() + } + + /// Returns true if current environment supports writing console output to + /// STDERR. + fn has_stderr_support() -> bool { + !Self::is_running_os400() + } + + /// 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() + .to_string_lossy() + .into_owned(), + shirabe_php_shim::PHP_OS.to_string(), + ]; + + shirabe_php_shim::stripos(&shirabe_php_shim::implode(";", &checks), "OS400").is_some() + } + + /// For testing only. Overwrites the inner `StreamOutput`'s private `stream` field, mirroring + /// what `Symfony\Component\Console\Tester\TesterTrait::initOutput` does via reflection on the + /// parent `StreamOutput::$stream` property of a `ConsoleOutput`. + pub fn __set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.inner.__set_stream(stream); + } + + 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 ConsoleOutputInterface for ConsoleOutput { + /// Creates a new output section. + fn section(&self) -> std::rc::Rc> { + // 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> { + self.stderr.borrow().clone() + } + + fn set_error_output(&self, error: std::rc::Rc>) { + *self.stderr.borrow_mut() = error; + } +} + +impl OutputInterface for ConsoleOutput { + fn write(&self, messages: &[String], newline: bool, options: i64) { + self.inner.write(messages, newline, options); + } + fn writeln(&self, messages: &[String], options: i64) { + self.inner.writeln(messages, options); + } + fn set_verbosity(&self, level: i64) { + self.inner.set_verbosity(level); + self.stderr.borrow().borrow().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); + self.stderr.borrow().borrow().set_decorated(decorated); + } + fn is_decorated(&self) -> bool { + self.inner.is_decorated() + } + fn set_formatter( + &self, + formatter: std::rc::Rc>, + ) { + self.inner.set_formatter(formatter.clone()); + self.stderr.borrow().borrow().set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc> { + self.inner.get_formatter() + } + fn as_console_output(&self) -> Option<&dyn ConsoleOutputInterface> { + Some(self) + } +} diff --git a/crates/shirabe-symfony-console/src/output/console_output_interface.rs b/crates/shirabe-symfony-console/src/output/console_output_interface.rs new file mode 100644 index 00000000..2702d439 --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/console_output_interface.rs @@ -0,0 +1,15 @@ +//! ref: composer/vendor/symfony/console/Output/ConsoleOutputInterface.php + +use crate::output::ConsoleSectionOutput; +use crate::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>; + + fn set_error_output(&self, error: std::rc::Rc>); + + fn section(&self) -> std::rc::Rc>; +} diff --git a/crates/shirabe-symfony-console/src/output/console_section_output.rs b/crates/shirabe-symfony-console/src/output/console_section_output.rs new file mode 100644 index 00000000..2b42ad02 --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/console_section_output.rs @@ -0,0 +1,203 @@ +//! ref: composer/vendor/symfony/console/Output/ConsoleSectionOutput.php + +use crate::formatter::OutputFormatterInterface; +use crate::helper::Helper; +use crate::output::OutputInterface; +use crate::output::output::DoWrite; +use crate::output::stream_output::StreamOutput; +use crate::terminal::Terminal; + +type Sections = + std::rc::Rc>>>>; + +#[derive(Debug)] +pub struct ConsoleSectionOutput { + inner: StreamOutput, + content: std::cell::RefCell>, + lines: std::cell::Cell, + 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::rc::Rc> { + 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) { + 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::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 = (self.get_display_length(&line_content) as f64 + / self.terminal.get_width() as f64) + .ceil(); + 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 = 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(&format!("\x1b[{}A", 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>, + ) { + self.inner.set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc> { + self.inner.get_formatter() + } +} diff --git a/crates/shirabe-symfony-console/src/output/output.rs b/crates/shirabe-symfony-console/src/output/output.rs new file mode 100644 index 00000000..98566a4e --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/output.rs @@ -0,0 +1,159 @@ +//! ref: composer/vendor/symfony/console/Output/Output.php + +use crate::formatter::OutputFormatter; +use crate::formatter::OutputFormatterInterface; +use crate::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, + formatter: std::cell::RefCell>>, +} + +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, + decorated: bool, + formatter: Option>>, + ) -> 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>, + ) { + *self.formatter.borrow_mut() = formatter; + } + + pub fn get_formatter(&self) -> std::rc::Rc> { + 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-symfony-console/src/output/output_interface.rs b/crates/shirabe-symfony-console/src/output/output_interface.rs new file mode 100644 index 00000000..cdc78144 --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/output_interface.rs @@ -0,0 +1,64 @@ +//! ref: composer/vendor/symfony/console/Output/OutputInterface.php + +use crate::formatter::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; + +/// 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>, + ); + + /// Returns current output formatter instance. + fn get_formatter(&self) -> std::rc::Rc>; + + /// Downcast hook standing in for PHP's `$output instanceof ConsoleOutputInterface` + /// (cf. `InputInterface::as_streamable`). Only `ConsoleOutput` returns `Some`. + fn as_console_output( + &self, + ) -> Option<&dyn crate::output::console_output_interface::ConsoleOutputInterface> { + None + } +} diff --git a/crates/shirabe-symfony-console/src/output/stream_output.rs b/crates/shirabe-symfony-console/src/output/stream_output.rs new file mode 100644 index 00000000..1ed90cac --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/stream_output.rs @@ -0,0 +1,200 @@ +//! ref: composer/vendor/symfony/console/Output/StreamOutput.php + +use crate::exception::InvalidArgumentException; +use crate::formatter::OutputFormatterInterface; +use crate::output::OutputInterface; +use crate::output::output::{DoWrite, Output}; +use crate::output::output_interface::VERBOSITY_NORMAL; +use shirabe_php_shim::php_regex; + +/// StreamOutput writes the output to a given stream. +/// +/// Usage: +/// +/// ```php +/// $output = new StreamOutput(fopen('php://stdout', 'w')); +/// ``` +/// +/// As `StreamOutput` can use any stream, you can also use a file: +/// +/// ```php +/// $output = new StreamOutput(fopen('/path/to/output.log', 'a', false)); +/// ``` +#[derive(Debug)] +pub struct StreamOutput { + inner: Output, + stream: shirabe_php_shim::PhpResource, +} + +impl StreamOutput { + /// `$verbosity` defaults to `self::VERBOSITY_NORMAL`; pass `None` to use it. + pub fn new( + stream: shirabe_php_shim::PhpResource, + verbosity: Option, + decorated: Option, + formatter: Option>>, + ) -> anyhow::Result> { + let verbosity = verbosity.unwrap_or(VERBOSITY_NORMAL); + + if shirabe_php_shim::get_resource_type(&stream) != "stream" { + return Ok(Err(InvalidArgumentException::new( + "The StreamOutput class needs a stream as its first argument.".to_string(), + ))); + } + + 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 + } + + /// For testing only. Overwrites the private `stream` field, mirroring what + /// `Symfony\Component\Console\Tester\TesterTrait::initOutput` does via reflection on the + /// `StreamOutput::$stream` property. + pub fn __set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.stream = 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().is_empty() { + 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() + .to_string_lossy(), + ) + .as_str(), + ) + { + return false; + } + + if cfg!(windows) && shirabe_php_shim::sapi_windows_vt100_support(stream) { + return true; + } + + if shirabe_php_shim::getenv("TERM_PROGRAM").as_deref() + == Some(std::ffi::OsStr::new("Hyper")) + || shirabe_php_shim::getenv("COLORTERM").is_some() + || shirabe_php_shim::getenv("ANSICON").is_some() + || shirabe_php_shim::getenv("ConEmuANSI").as_deref() == Some(std::ffi::OsStr::new("ON")) + { + return true; + } + + let term = shirabe_php_shim::getenv("TERM") + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + if "dumb" == term { + return false; + } + + // See https://github.com/chalk/supports-color/blob/d4f413efaf8da045c5ab440ed418ef02dbb28bf1/index.js#L157 + let mut matches: Vec> = Vec::new(); + shirabe_php_shim::preg_match( + php_regex!( + "/^((screen|xterm|vt100|vt220|putty|rxvt|ansi|cygwin|linux).*)|(.*-256(color)?(-bce)?)$/" + ), + &term, + &mut matches, + ) + } +} + +/// 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() + .to_string_lossy() + .into_owned(); + 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: &[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>, + ) { + self.inner.set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc> { + self.inner.get_formatter() + } +} diff --git a/crates/shirabe-symfony-console/src/output/trimmed_buffer_output.rs b/crates/shirabe-symfony-console/src/output/trimmed_buffer_output.rs new file mode 100644 index 00000000..9f8be71a --- /dev/null +++ b/crates/shirabe-symfony-console/src/output/trimmed_buffer_output.rs @@ -0,0 +1,99 @@ +//! ref: composer/vendor/symfony/console/Output/TrimmedBufferOutput.php + +use crate::exception::InvalidArgumentException; +use crate::formatter::OutputFormatterInterface; +use crate::output::OutputInterface; +use crate::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, +} + +impl TrimmedBufferOutput { + pub fn new( + max_length: i64, + verbosity: Option, + decorated: bool, + formatter: Option>>, + ) -> Result { + if max_length <= 0 { + return Err(InvalidArgumentException::new(format!( + "\"{}()\" expects a strictly positive maxLength. Got {}.", + "Symfony\\Component\\Console\\Output\\TrimmedBufferOutput::__construct", max_length, + ))); + } + + 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>, + ) { + self.inner.set_formatter(formatter); + } + fn get_formatter(&self) -> std::rc::Rc> { + self.inner.get_formatter() + } +} diff --git a/crates/shirabe-symfony-console/src/question.rs b/crates/shirabe-symfony-console/src/question.rs new file mode 100644 index 00000000..06d03b40 --- /dev/null +++ b/crates/shirabe-symfony-console/src/question.rs @@ -0,0 +1,7 @@ +mod choice_question; +mod confirmation_question; +mod question; + +pub use choice_question::*; +pub use confirmation_question::*; +pub use question::*; diff --git a/crates/shirabe-symfony-console/src/question/choice_question.rs b/crates/shirabe-symfony-console/src/question/choice_question.rs new file mode 100644 index 00000000..95717f1a --- /dev/null +++ b/crates/shirabe-symfony-console/src/question/choice_question.rs @@ -0,0 +1,286 @@ +//! ref: composer/vendor/symfony/console/Question/ChoiceQuestion.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::logic_exception::LogicException; +use crate::question::Question; +use crate::question::QuestionInterface; +use indexmap::IndexMap; +use shirabe_php_shim::{PhpMixed, php_regex}; + +/// Represents a choice question. +#[derive(Debug)] +pub struct ChoiceQuestion { + inner: Question, + choices: IndexMap, + multiselect: bool, + prompt: String, + error_message: String, +} + +impl ChoiceQuestion { + /// `$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, + default: Option, + ) -> Result { + if choices.is_empty() { + return Err(LogicException::new( + "Choice question must have at least 1 choice available.".to_string(), + )); + } + + 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 { + &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 + } + + /// Sets the prompt for choices. + pub fn set_prompt(&mut self, prompt: String) -> &mut Self { + self.prompt = prompt; + + self + } + + /// Inherited from Question. Sets the maximum number of attempts. + pub fn set_max_attempts( + &mut self, + attempts: Option, + ) -> Result<&mut Self, InvalidArgumentException> { + self.inner.set_max_attempts(attempts)?; + + Ok(self) + } + + /// 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) -> Result> { + 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| { + let selected = selected.unwrap_or(PhpMixed::Null); + + let selected_choices: Vec = if multiselect { + // Check for a separated comma values + let mut matches: Vec> = Vec::new(); + if !shirabe_php_shim::preg_match( + php_regex!("/^[^,]+(?:,[^,]+)*$/"), + &shirabe_php_shim::strval(&selected), + &mut matches, + ) { + return Err(InvalidArgumentException::new(shirabe_php_shim::sprintf( + &error_message, + std::slice::from_ref(&selected), + ))); + } + + shirabe_php_shim::explode(",", &shirabe_php_shim::strval(&selected)) + .into_iter() + .map(PhpMixed::String) + .collect() + } else { + vec![selected] + }; + + 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 = Vec::new(); + for value in &selected_choices { + let mut results: Vec = Vec::new(); + for (key, choice) in &choices { + if (*choice) == *value { + results.push(key.clone()); + } + } + + if results.len() > 1 { + return Err(InvalidArgumentException::new(format!( + "The provided answer is ambiguous. Value should be one of \"{}\".", + shirabe_php_shim::implode("\" or \"", &results), + ))); + } + + // 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::new(shirabe_php_shim::sprintf( + &error_message, + std::slice::from_ref(value), + ))); + } + + // 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)); + } + + Ok(multiselect_choices + .into_iter() + .next() + .unwrap_or(PhpMixed::Bool(false))) + }) + } +} + +impl QuestionInterface for ChoiceQuestion { + fn get_question(&self) -> &str { + self.inner.get_question() + } + + fn get_default(&self) -> PhpMixed { + self.inner.get_default() + } + + fn is_multiline(&self) -> bool { + self.inner.is_multiline() + } + + fn is_hidden(&self) -> bool { + self.inner.is_hidden() + } + + fn is_hidden_fallback(&self) -> bool { + self.inner.is_hidden_fallback() + } + + fn get_autocompleter_values(&self) -> Option> { + self.inner.get_autocompleter_values() + } + + fn get_autocompleter_callback(&self) -> Option<&dyn Fn(&str) -> Option>> { + self.inner.get_autocompleter_callback() + } + + fn get_validator( + &self, + ) -> Option<&dyn Fn(Option) -> Result> { + self.inner.get_validator() + } + + fn get_max_attempts(&self) -> Option { + self.inner.get_max_attempts() + } + + fn get_normalizer(&self) -> Option<&dyn Fn(PhpMixed) -> PhpMixed> { + self.inner.get_normalizer() + } + + fn is_trimmable(&self) -> bool { + self.inner.is_trimmable() + } + + fn as_choice(&self) -> Option<&ChoiceQuestion> { + Some(self) + } +} + +/// 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) -> IndexMap { + choices + .iter() + .map(|(k, v)| (k.clone(), shirabe_php_shim::strval(v))) + .collect() +} diff --git a/crates/shirabe-symfony-console/src/question/confirmation_question.rs b/crates/shirabe-symfony-console/src/question/confirmation_question.rs new file mode 100644 index 00000000..34db281b --- /dev/null +++ b/crates/shirabe-symfony-console/src/question/confirmation_question.rs @@ -0,0 +1,113 @@ +//! ref: composer/vendor/symfony/console/Question/ConfirmationQuestion.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::question::Question; +use crate::question::QuestionInterface; +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 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> = Vec::new(); + shirabe_php_shim::preg_match( + ®ex, + &shirabe_php_shim::strval(&answer), + &mut matches, + ) + }; + + // 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) + }) + } +} + +impl QuestionInterface for ConfirmationQuestion { + fn get_question(&self) -> &str { + self.inner.get_question() + } + + fn get_default(&self) -> PhpMixed { + self.inner.get_default() + } + + fn is_multiline(&self) -> bool { + self.inner.is_multiline() + } + + fn is_hidden(&self) -> bool { + self.inner.is_hidden() + } + + fn is_hidden_fallback(&self) -> bool { + self.inner.is_hidden_fallback() + } + + fn get_autocompleter_values(&self) -> Option> { + self.inner.get_autocompleter_values() + } + + fn get_autocompleter_callback(&self) -> Option<&dyn Fn(&str) -> Option>> { + self.inner.get_autocompleter_callback() + } + + fn get_validator( + &self, + ) -> Option<&dyn Fn(Option) -> Result> { + self.inner.get_validator() + } + + fn get_max_attempts(&self) -> Option { + self.inner.get_max_attempts() + } + + fn get_normalizer(&self) -> Option<&dyn Fn(PhpMixed) -> PhpMixed> { + self.inner.get_normalizer() + } + + fn is_trimmable(&self) -> bool { + self.inner.is_trimmable() + } + + fn as_confirmation(&self) -> Option<&ConfirmationQuestion> { + Some(self) + } +} diff --git a/crates/shirabe-symfony-console/src/question/question.rs b/crates/shirabe-symfony-console/src/question/question.rs new file mode 100644 index 00000000..7f96111b --- /dev/null +++ b/crates/shirabe-symfony-console/src/question/question.rs @@ -0,0 +1,371 @@ +//! ref: composer/vendor/symfony/console/Question/Question.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::exception::logic_exception::LogicException; +use crate::question::choice_question::ChoiceQuestion; +use crate::question::confirmation_question::ConfirmationQuestion; +use shirabe_php_shim::PhpMixed; + +/// Polymorphic boundary for the Symfony Console Question hierarchy. +/// +/// PHP has no `QuestionInterface`; `Question` is a concrete class extended by +/// `ChoiceQuestion`/`ConfirmationQuestion`. Modelling those subclasses as +/// `inner: Question` composition loses subtype identity, so consumers that take +/// a `Question` and run `instanceof` checks are expressed here as a trait whose +/// `as_choice`/`as_confirmation` downcasts stand in for `instanceof`. +pub trait QuestionInterface: std::fmt::Debug { + fn get_question(&self) -> &str; + + fn get_default(&self) -> PhpMixed; + + fn is_multiline(&self) -> bool; + + fn is_hidden(&self) -> bool; + + fn is_hidden_fallback(&self) -> bool; + + fn get_autocompleter_values(&self) -> Option>; + + fn get_autocompleter_callback(&self) -> Option<&dyn Fn(&str) -> Option>>; + + fn get_validator( + &self, + ) -> Option<&dyn Fn(Option) -> Result>; + + fn get_max_attempts(&self) -> Option; + + fn get_normalizer(&self) -> Option<&dyn Fn(PhpMixed) -> PhpMixed>; + + fn is_trimmable(&self) -> bool; + + /// Models `$question instanceof ChoiceQuestion`. + fn as_choice(&self) -> Option<&ChoiceQuestion> { + None + } + + /// Models `$question instanceof ConfirmationQuestion`. + fn as_confirmation(&self) -> Option<&ConfirmationQuestion> { + None + } +} + +/// Represents a Question. +pub struct Question { + question: String, + attempts: Option, + hidden: bool, + hidden_fallback: bool, + autocompleter_callback: Option Option>>>, + validator: Option) -> Result>>, + default: Option, + normalizer: Option 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 { + /// `$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) -> Self { + Self { + question, + attempts: None, + hidden: false, + hidden_fallback: true, + autocompleter_callback: None, + validator: None, + default, + normalizer: None, + trimmable: true, + multiline: false, + } + } + + /// Returns the question. + pub fn get_question(&self) -> &str { + &self.question + } + + /// Returns the default answer. + pub fn get_default(&self) -> PhpMixed { + self.default.clone().unwrap_or(PhpMixed::Null) + } + + /// Returns whether the user response accepts newline characters. + pub fn is_multiline(&self) -> bool { + self.multiline + } + + /// Sets whether the user response should accept newline characters. + pub fn set_multiline(&mut self, multiline: bool) -> &mut Self { + self.multiline = multiline; + + self + } + + /// Returns whether the user response must be hidden. + pub fn is_hidden(&self) -> bool { + self.hidden + } + + /// 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::new( + "A hidden question cannot use the autocompleter.".to_string(), + )); + } + + self.hidden = hidden; + + Ok(self) + } + + /// 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 + } + + /// 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 + } + + /// Gets values for the autocompleter. + pub fn get_autocompleter_values(&self) -> Option> { + 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, + ) -> Result<&mut Self, LogicException> { + let callback: Option Option>>> = 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 = + array.keys().map(|k| PhpMixed::String(k.clone())).collect(); + merged.extend(array.values().cloned()); + merged + } else { + // array_values($values) + match &values { + PhpMixed::List(list) => list.to_vec(), + PhpMixed::Array(array) => array.values().cloned().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 = match values { + PhpMixed::List(list) => list.into_iter().collect(), + PhpMixed::Array(array) => array.into_values().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>> { + 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 Option>>>, + ) -> Result<&mut Self, LogicException> { + if self.hidden && callback.is_some() { + return Err(LogicException::new( + "A hidden question cannot use the autocompleter.".to_string(), + )); + } + + self.autocompleter_callback = callback; + + Ok(self) + } + + /// Sets a validator for the question. + pub fn set_validator( + &mut self, + validator: Option< + Box) -> Result>, + >, + ) -> &mut Self { + self.validator = validator; + + self + } + + /// Gets the validator for the question. + pub fn get_validator( + &self, + ) -> Option<&dyn Fn(Option) -> Result> { + 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, + ) -> Result<&mut Self, InvalidArgumentException> { + if let Some(attempts) = attempts + && attempts < 1 + { + return Err(InvalidArgumentException::new( + "Maximum number of attempts must be a positive value.".to_string(), + )); + } + + 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 { + 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 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'))`. + // A `List` has only sequential int keys, so it is never associative. An `Array` is + // associative only when at least one key is a genuine string key; PHP normalizes + // canonical-integer string keys (e.g. "0", "12") back to int keys, so those do not count. + // The same heuristic (a key is "string" iff it does not parse as an i64) is used by + // ConsoleIO::select when computing `$isAssoc` over the choice map. + pub(crate) fn is_assoc(array: &PhpMixed) -> bool { + match array { + PhpMixed::Array(map) => map.keys().any(|key| key.parse::().is_err()), + _ => false, + } + } + + pub fn is_trimmable(&self) -> bool { + self.trimmable + } + + pub fn set_trimmable(&mut self, trimmable: bool) -> &mut Self { + self.trimmable = trimmable; + + self + } +} + +impl QuestionInterface for Question { + fn get_question(&self) -> &str { + self.get_question() + } + + fn get_default(&self) -> PhpMixed { + self.get_default() + } + + fn is_multiline(&self) -> bool { + self.is_multiline() + } + + fn is_hidden(&self) -> bool { + self.is_hidden() + } + + fn is_hidden_fallback(&self) -> bool { + self.is_hidden_fallback() + } + + fn get_autocompleter_values(&self) -> Option> { + self.get_autocompleter_values() + } + + fn get_autocompleter_callback(&self) -> Option<&dyn Fn(&str) -> Option>> { + self.get_autocompleter_callback() + } + + fn get_validator( + &self, + ) -> Option<&dyn Fn(Option) -> Result> { + self.get_validator() + } + + fn get_max_attempts(&self) -> Option { + self.get_max_attempts() + } + + fn get_normalizer(&self) -> Option<&dyn Fn(PhpMixed) -> PhpMixed> { + self.get_normalizer() + } + + fn is_trimmable(&self) -> bool { + self.is_trimmable() + } +} diff --git a/crates/shirabe-symfony-console/src/signal_registry.rs b/crates/shirabe-symfony-console/src/signal_registry.rs new file mode 100644 index 00000000..a07ca01f --- /dev/null +++ b/crates/shirabe-symfony-console/src/signal_registry.rs @@ -0,0 +1,3 @@ +pub mod signal_registry; + +pub use signal_registry::*; diff --git a/crates/shirabe-symfony-console/src/signal_registry/signal_registry.rs b/crates/shirabe-symfony-console/src/signal_registry/signal_registry.rs new file mode 100644 index 00000000..f1f67d09 --- /dev/null +++ b/crates/shirabe-symfony-console/src/signal_registry/signal_registry.rs @@ -0,0 +1,102 @@ +//! ref: composer/vendor/symfony/console/SignalRegistry/SignalRegistry.php + +use indexmap::IndexMap; + +/// A signal handler receives the signal number and whether a further handler follows. +pub type SignalHandler = Box; + +pub struct SignalRegistry { + // signal number => list of handlers + signal_handlers: IndexMap>, +} + +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-symfony-console/src/style.rs b/crates/shirabe-symfony-console/src/style.rs new file mode 100644 index 00000000..efc54e1c --- /dev/null +++ b/crates/shirabe-symfony-console/src/style.rs @@ -0,0 +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-symfony-console/src/style/output_style.rs b/crates/shirabe-symfony-console/src/style/output_style.rs new file mode 100644 index 00000000..17c9489e --- /dev/null +++ b/crates/shirabe-symfony-console/src/style/output_style.rs @@ -0,0 +1,122 @@ +//! ref: composer/vendor/symfony/console/Style/OutputStyle.php + +use crate::formatter::OutputFormatterInterface; +use crate::helper::ProgressBar; +use crate::output::ConsoleOutputInterface; +use crate::output::OutputInterface; +use crate::output::output_interface::OUTPUT_NORMAL; + +/// Decorates output to add console style guide helpers. +#[derive(Debug)] +pub struct OutputStyle { + output: std::rc::Rc>, +} + +impl OutputStyle { + pub fn new(output: std::rc::Rc>) -> Self { + Self { output } + } + + pub fn create_progress_bar(&self, max: i64) -> ProgressBar { + ProgressBar::new(self.output.clone(), max, 1.0 / 25.0) + } + + pub fn new_line(&self, count: i64) { + self.output.borrow().write( + &[shirabe_php_shim::str_repeat( + shirabe_php_shim::PHP_EOL, + count as usize, + )], + false, + OUTPUT_NORMAL, + ); + } + + pub(crate) fn get_error_output(&self) -> std::rc::Rc> { + // 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() + .get_error_output() + } + + fn is_console_output_interface( + output: &std::rc::Rc>, + ) -> bool { + // ConsoleOutput is the only OutputInterface implementor that also implements + // ConsoleOutputInterface, so `instanceof ConsoleOutputInterface` reduces to this downcast. + shirabe_php_shim::AsAny::as_any(&*output.borrow()) + .downcast_ref::() + .is_some() + } + + /// PHP casts to `ConsoleOutputInterface`; `ConsoleOutput` being its only implementor, a + /// borrow of the concrete type serves as the cast result. + fn as_console_output_interface( + output: &std::rc::Rc>, + ) -> Option> { + std::cell::Ref::filter_map(output.borrow(), |output| { + output + .as_any() + .downcast_ref::() + }) + .ok() + } +} + +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: std::rc::Rc>, + ) { + self.output.borrow().set_formatter(formatter); + } + + fn get_formatter(&self) -> std::rc::Rc> { + self.output.borrow().get_formatter() + } +} diff --git a/crates/shirabe-symfony-console/src/style/style_interface.rs b/crates/shirabe-symfony-console/src/style/style_interface.rs new file mode 100644 index 00000000..96745de1 --- /dev/null +++ b/crates/shirabe-symfony-console/src/style/style_interface.rs @@ -0,0 +1,74 @@ +//! ref: composer/vendor/symfony/console/Style/StyleInterface.php + +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); + + /// 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, rows: Vec); + + /// Asks a question. + fn ask( + &mut self, + question: &str, + default: Option<&str>, + validator: Option) -> anyhow::Result>>, + ) -> PhpMixed; + + /// Asks a question with the user input hidden. + fn ask_hidden( + &mut self, + question: &str, + validator: Option) -> anyhow::Result>>, + ) -> 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, + default: Option, + ) -> 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-symfony-console/src/style/symfony_style.rs b/crates/shirabe-symfony-console/src/style/symfony_style.rs new file mode 100644 index 00000000..486acf2e --- /dev/null +++ b/crates/shirabe-symfony-console/src/style/symfony_style.rs @@ -0,0 +1,753 @@ +//! ref: composer/vendor/symfony/console/Style/SymfonyStyle.php + +use crate::exception::invalid_argument_exception::InvalidArgumentException; +use crate::formatter::OutputFormatter; +use crate::formatter::OutputFormatterInterface; +use crate::helper::Helper; +use crate::helper::ProgressBar; +use crate::helper::SymfonyQuestionHelper; +use crate::helper::Table; +use crate::helper::TableCell; +use crate::helper::TableSeparator; +use crate::helper::question_helper::QuestionHelperInterface; +use crate::helper::table::{Cell, Row}; +use crate::input::InputInterface; +use crate::output::ConsoleOutputInterface; +use crate::output::OutputInterface; +use crate::output::TrimmedBufferOutput; +use crate::output::console_output::ConsoleOutput; +use crate::output::output_interface::OUTPUT_NORMAL; +use crate::question::ChoiceQuestion; +use crate::question::ConfirmationQuestion; +use crate::question::Question; +use crate::question::QuestionInterface; +use crate::style::output_style::OutputStyle; +use crate::style::style_interface::StyleInterface; +use crate::terminal::Terminal; +use shirabe_php_shim::PhpMixed; + +/// Output decorator helpers for the Symfony Style Guide. +#[derive(Debug)] +pub struct SymfonyStyle { + inner: OutputStyle, + input: std::rc::Rc>, + output: std::rc::Rc>, + question_helper: Option, + progress_bar: Option, + line_length: i64, + buffered_output: TrimmedBufferOutput, +} + +pub const MAX_LINE_LENGTH: i64 = 120; + +/// A `definition_list` entry. PHP types it as `string|array|TableSeparator`; any other type is +/// rejected with an `InvalidArgumentException` (a `LogicException`), which this enum makes +/// unrepresentable. +#[derive(Debug)] +pub enum DefinitionListItem { + String(String), + Array(indexmap::IndexMap), + TableSeparator(TableSeparator), +} + +impl SymfonyStyle { + pub fn new( + input: std::rc::Rc>, + output: std::rc::Rc>, + ) -> Self { + let buffered_output = TrimmedBufferOutput::new( + if cfg!(windows) { 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 = std::cmp::min(width - cfg!(windows) 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, + } + } + + /// 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 = if shirabe_php_shim::is_array(&messages) { + match messages { + PhpMixed::Array(entries) => entries.into_values().collect(), + PhpMixed::List(items) => items, + _ => unreachable!("value is an array past the is_array guard"), + } + } 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(PhpMixed::String).collect()), + OUTPUT_NORMAL, + ); + self.new_line(1); + } + + /// Formats a command comment. + pub fn comment(&mut self, message: PhpMixed) { + self.block( + message, + None, + None, + " // ", + false, + false, + ); + } + + /// Formats an info message. + pub fn info(&mut self, message: PhpMixed) { + self.block(message, Some("INFO"), Some("fg=green"), " ", true, true); + } + + /// Formats a horizontal table. + pub fn horizontal_table(&mut self, headers: Vec, rows: Vec) { + self.create_table() + .set_horizontal(true) + .set_headers(headers) + .set_rows(rows) + .render(); + + self.new_line(1); + } + + /// 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) { + let mut headers: Vec = Vec::new(); + let mut row: Vec = Vec::new(); + for value in list { + match value { + DefinitionListItem::TableSeparator(separator) => { + headers.push(Cell::Separator(separator.clone())); + row.push(Cell::Separator(separator)); + } + DefinitionListItem::String(value) => { + headers.push(Cell::Cell( + TableCell::new(&value, { + let mut options = indexmap::IndexMap::new(); + options.insert( + "colspan".to_string(), + crate::helper::table_cell::TableCellOption::Int(2), + ); + options + }) + .expect("colspan is a valid TableCell option"), + )); + row.push(Cell::Null); + } + DefinitionListItem::Array(value) => { + // $headers[] = key($value); $row[] = current($value); + let first_key = value + .keys() + .next() + .map(|k| PhpMixed::String(k.clone())) + .unwrap_or(PhpMixed::Null); + let first_value = value + .values() + .next() + .cloned() + .unwrap_or(PhpMixed::Bool(false)); + headers.push(Cell::from(first_key)); + row.push(Cell::from(first_value)); + } + } + } + + self.horizontal_table(headers, vec![Row::Cells(row)]); + } + + /// @see ProgressBar::iterate() + /// + /// PHP returns a generator (`yield from`); this port evaluates eagerly, following + /// `ProgressBar::iterate`. + pub fn progress_iterate( + &mut self, + iterable: Vec<(PhpMixed, PhpMixed)>, + max: Option, + ) -> anyhow::Result> { + let yielded = self.create_progress_bar(0).iterate(iterable, max)?; + + self.new_line(2); + + Ok(yielded) + } + + pub fn ask_question(&mut self, question: &impl QuestionInterface) -> PhpMixed { + if self.input.borrow().is_interactive() { + self.auto_prepend_block(); + } + + if self.question_helper.is_none() { + self.question_helper = Some(SymfonyQuestionHelper::new()); + } + + // TODO(phase-c): PHP passes `$this` as the OutputInterface, so SymfonyQuestionHelper's + // write_error renders through SymfonyStyle::error; SymfonyStyle is not an OutputInterface + // trait object here, so the raw output is passed instead. + 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. The double + // `Result` is collapsed here 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 + } + + /// 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 create_table(&mut self) -> Table { + let output: std::rc::Rc> = + if Self::is_console_output_interface(&self.output) { + Self::as_console_output_interface(&self.output) + .unwrap() + .section() + } 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("%s".to_string()); + + let mut table = Table::new(output); + let _ = table.set_style(crate::helper::table::StyleName::Style(style)); + table + } + + pub fn create_progress_bar(&self, max: i64) -> ProgressBar { + let mut progress_bar = self.inner.create_progress_bar(max); + + if !cfg!(windows) + || shirabe_php_shim::getenv("TERM_PROGRAM").as_deref() + == Some(std::ffi::OsStr::new("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 + } + + fn get_progress_bar(&mut self) -> &mut ProgressBar { + // PHP throws RuntimeException('The ProgressBar is not started.'). Reaching this without a + // prior progress_start() call is a caller bug, and the StyleInterface signatures carry no + // Result, so panic. + self.progress_bar + .as_mut() + .expect("The ProgressBar is not started.") + } + + 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, + messages: Vec, + r#type: Option<&str>, + style: Option<&str>, + prefix: &str, + padding: bool, + escape: bool, + ) -> Vec { + 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 = 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 = format!("[{}] ", 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 = std::cmp::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( + " ", + (self.line_length + - Helper::width(&Helper::remove_decoration( + &mut *self.output.borrow().get_formatter().borrow_mut(), + line, + ))) + .max(0) as usize, + )); + + if let Some(style) = style { + *line = format!("<{}>{}", style, line.clone()); + } + } + + lines + } + + fn get_formatter(&self) -> std::rc::Rc> { + self.output.borrow().get_formatter() + } + + fn is_console_output_interface( + output: &std::rc::Rc>, + ) -> bool { + // ConsoleOutput is the only OutputInterface implementor that also implements + // ConsoleOutputInterface, so `instanceof ConsoleOutputInterface` reduces to this downcast. + shirabe_php_shim::AsAny::as_any(&*output.borrow()) + .downcast_ref::() + .is_some() + } + + /// PHP casts to `ConsoleOutputInterface`; `ConsoleOutput` being its only implementor, a + /// borrow of the concrete type serves as the cast result. + fn as_console_output_interface( + output: &std::rc::Rc>, + ) -> Option> { + std::cell::Ref::filter_map(output.borrow(), |output| { + output.as_any().downcast_ref::() + }) + .ok() + } + + fn php_string(value: &PhpMixed) -> String { + shirabe_php_shim::strval(value) + } + + /// 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) -> anyhow::Result>>, + ) -> Option) -> Result>> { + validator.map(|validator| { + Box::new(move |value: Option| { + validator(value).map_err(|e| InvalidArgumentException::new(e.to_string())) + }) + as Box) -> Result> + }) + } + + /// {@inheritdoc} + pub fn writeln(&mut self, messages: PhpMixed, r#type: i64) { + let messages: Vec = if !shirabe_php_shim::is_iterable(&messages) { + vec![messages] + } else { + match messages { + PhpMixed::Array(entries) => entries.into_values().collect(), + PhpMixed::List(items) => items, + _ => unreachable!("value is iterable past the is_iterable guard"), + } + }; + + for message in messages { + let message = Self::php_string(&message); + self.inner.writeln(std::slice::from_ref(&message), 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 = if !shirabe_php_shim::is_iterable(&messages) { + vec![messages] + } else { + match messages { + PhpMixed::Array(entries) => entries.into_values().collect(), + PhpMixed::List(items) => items, + _ => unreachable!("value is iterable past the is_iterable guard"), + } + }; + + for message in messages { + let message = Self::php_string(&message); + self.inner + .write(std::slice::from_ref(&message), 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![ + PhpMixed::String(format!( + "{}", + OutputFormatter::escape_trailing_backslash(message), + )), + PhpMixed::String(format!( + "{}", + 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![ + PhpMixed::String(format!( + "{}", + OutputFormatter::escape_trailing_backslash(message), + )), + PhpMixed::String(format!( + "{}", + 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) { + self.auto_prepend_text(); + let elements: Vec = shirabe_php_shim::array_map( + |element: &PhpMixed| PhpMixed::String(format!(" * {}", element.clone())), + &elements, + ); + + self.writeln( + PhpMixed::List(elements.into_iter().collect()), + OUTPUT_NORMAL, + ); + self.new_line(1); + } + + /// {@inheritdoc} + fn text(&mut self, message: PhpMixed) { + self.auto_prepend_text(); + + let messages: Vec = if shirabe_php_shim::is_array(&message) { + match message { + PhpMixed::Array(entries) => entries.into_values().collect(), + PhpMixed::List(items) => items, + _ => unreachable!("value is an array past the is_array guard"), + } + } else { + vec![message] + }; + for message in messages { + self.writeln(PhpMixed::String(format!(" {}", 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, rows: Vec) { + self.create_table() + .set_headers(headers.into_iter().map(Cell::from).collect()) + .set_rows(rows.into_iter().map(Row::from).collect()) + .render(); + + self.new_line(1); + } + + /// {@inheritdoc} + fn ask( + &mut self, + question: &str, + default: Option<&str>, + validator: Option) -> anyhow::Result>>, + ) -> PhpMixed { + 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) + } + + /// {@inheritdoc} + fn ask_hidden( + &mut self, + question: &str, + validator: Option) -> anyhow::Result>>, + ) -> 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) + } + + /// {@inheritdoc} + fn confirm(&mut self, question: &str, default: bool) -> bool { + let answer = self.ask_question(&ConfirmationQuestion::new( + question.to_string(), + default, + "/^y/i".to_string(), + )); + + shirabe_php_shim::boolval(&answer) + } + + /// {@inheritdoc} + fn choice( + &mut self, + question: &str, + choices: Vec, + default: Option, + ) -> PhpMixed { + let default = if let Some(default) = default { + let values = shirabe_php_shim::array_flip(&PhpMixed::List(choices.to_vec())); + // $default = $values[$default] ?? $default; + let resolved = match &values { + PhpMixed::Array(map) => map.get(&default.to_string()).cloned(), + _ => None, + }; + Some(resolved.unwrap_or(default)) + } else { + None + }; + + // PHP: return $this->askQuestion(new ChoiceQuestion($question, $choices, $default)); + let choices_map: indexmap::IndexMap = choices + .into_iter() + .enumerate() + .map(|(i, c)| (i.to_string(), c)) + .collect(); + let choice_question = ChoiceQuestion::new(question.to_string(), choices_map, default) + .expect("choice() always provides at least one choice"); + self.ask_question(&choice_question) + } + + /// {@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, + ); + } + + /// {@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); + } + + /// {@inheritdoc} + fn progress_advance(&mut self, step: i64) { + self.get_progress_bar().advance(step); + } + + /// {@inheritdoc} + fn progress_finish(&mut self) { + self.get_progress_bar().finish(); + self.new_line(2); + self.progress_bar = None; + } +} diff --git a/crates/shirabe-symfony-console/src/terminal.rs b/crates/shirabe-symfony-console/src/terminal.rs new file mode 100644 index 00000000..9af712ad --- /dev/null +++ b/crates/shirabe-symfony-console/src/terminal.rs @@ -0,0 +1,253 @@ +//! ref: composer/vendor/symfony/console/Terminal.php + +use shirabe_php_shim::{PhpMixed, php_regex}; +use std::cell::Cell; + +thread_local! { + static WIDTH: Cell> = const { Cell::new(None) }; + static HEIGHT: Cell> = const { Cell::new(None) }; + static STTY: Cell> = const { Cell::new(None) }; +} + +#[derive(Debug)] +pub struct Terminal; + +impl Default for Terminal { + fn default() -> Self { + Self::new() + } +} + +impl Terminal { + pub fn new() -> Self { + Terminal + } + + /// Gets the terminal width. + pub fn get_width(&self) -> i64 { + let width = shirabe_php_shim::getenv("COLUMNS"); + if let Some(width) = width { + return shirabe_php_shim::intval(&PhpMixed::String(shirabe_php_shim::trim( + &width.to_string_lossy(), + 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 { + let height = shirabe_php_shim::getenv("LINES"); + if let Some(height) = height { + return shirabe_php_shim::intval(&PhpMixed::String(shirabe_php_shim::trim( + &height.to_string_lossy(), + 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 cfg!(windows) { "NUL" } else { "/dev/null" } + )) + .is_some(); + STTY.with(|s| s.set(Some(result))); + result + } + + fn init_dimensions() { + if cfg!(windows) { + let ansicon = shirabe_php_shim::getenv("ANSICON"); + let mut matches: Vec> = Vec::new(); + if let Some(ansicon) = &ansicon + && shirabe_php_shim::preg_match( + php_regex!("/^(\\d+)x(\\d+)(?: \\((\\d+)x(\\d+)\\))?$/"), + &shirabe_php_shim::trim(&ansicon.to_string_lossy(), None), + &mut matches, + ) + { + // 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") && { + let stream = shirabe_php_shim::php_fopen_resource("php://stdout", "w"); + shirabe_php_shim::sapi_windows_vt100_support(&stream) + } + } + + /// 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> = Vec::new(); + if shirabe_php_shim::preg_match( + php_regex!("/rows.(\\d+);.columns.(\\d+);/i"), + &stty_string, + &mut matches, + ) { + // 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( + php_regex!("/;.(\\d+).rows;.(\\d+).columns/i"), + &stty_string, + &mut matches, + ) { + // 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> { + let info = Self::read_from_process("mode CON"); + + let info = info?; + let mut matches: Vec> = Vec::new(); + if !shirabe_php_shim::preg_match( + php_regex!("/--------+\\r?\\n.+?(\\d+)\\r?\\n.+?(\\d+)\\r?\\n/"), + &info, + &mut matches, + ) { + 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 { + Self::read_from_process("stty -a | grep columns") + } + + fn read_from_process(command: &str) -> Option { + if !shirabe_php_shim::function_exists("proc_open") { + return None; + } + + // Sparse PHP descriptorspec `[1 => ['pipe', 'w'], 2 => ['pipe', 'w']]`: fd 0 is inherited. + let descriptorspec = [ + shirabe_php_shim::Descriptor::Inherit, + shirabe_php_shim::Descriptor::Pipe("w".to_string()), + shirabe_php_shim::Descriptor::Pipe("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: indexmap::IndexMap = + indexmap::IndexMap::new(); + let process = match shirabe_php_shim::proc_open( + command, + &descriptorspec, + &mut pipes, + None, + None, + None, + ) { + Ok(process) => process, + Err(_) => return None, + }; + + let info = pipes + .get(&1) + .and_then(shirabe_php_shim::stream_get_contents); + if let Some(pipe) = pipes.get(&1) { + shirabe_php_shim::fclose(pipe); + } + if let Some(pipe) = pipes.get(&2) { + shirabe_php_shim::fclose(pipe); + } + shirabe_php_shim::proc_close(&process); + + if cp != 0 { + shirabe_php_shim::sapi_windows_cp_set(cp); + } + + info + } +} diff --git a/crates/shirabe-symfony-console/src/tester.rs b/crates/shirabe-symfony-console/src/tester.rs new file mode 100644 index 00000000..1a7f6d0c --- /dev/null +++ b/crates/shirabe-symfony-console/src/tester.rs @@ -0,0 +1,3 @@ +pub mod command_completion_tester; + +pub use command_completion_tester::*; diff --git a/crates/shirabe-symfony-console/src/tester/command_completion_tester.rs b/crates/shirabe-symfony-console/src/tester/command_completion_tester.rs new file mode 100644 index 00000000..81cbdab3 --- /dev/null +++ b/crates/shirabe-symfony-console/src/tester/command_completion_tester.rs @@ -0,0 +1,54 @@ +//! ref: composer/vendor/symfony/console/Tester/CommandCompletionTester.php + +use crate::command::command::Command; +use crate::completion::completion_input::CompletionInput; +use crate::completion::completion_suggestions::CompletionSuggestions; + +/// Eases the testing of command completion. +#[derive(Debug)] +pub struct CommandCompletionTester { + command: std::rc::Rc>, +} + +impl CommandCompletionTester { + pub fn new(command: std::rc::Rc>) -> Self { + Self { command } + } + + /// Create completion suggestions from input tokens. + pub fn complete(&self, input: &[&str]) -> anyhow::Result> { + let mut input: Vec = input.iter().map(|s| s.to_string()).collect(); + let current_index = input.len() as i64; + if input.last().map(String::as_str) == Some("") { + input.pop(); + } + // array_unshift($input, $this->command->getName()) + input.insert(0, self.command.borrow().get_name().unwrap_or_default()); + + let mut completion_input = CompletionInput::from_tokens(input, current_index)?; + { + let command_ref = self.command.borrow(); + let definition = command_ref.get_definition(); + completion_input.bind(&definition)?; + } + let mut suggestions = CompletionSuggestions::new(); + + self.command + .borrow() + .complete(&completion_input, &mut suggestions)?; + + let mut result: Vec = suggestions + .get_option_suggestions() + .iter() + .map(|option| format!("--{}", option.get_name())) + .collect(); + // array_map('strval', ... $suggestions->getValueSuggestions()) + result.extend( + suggestions + .get_value_suggestions() + .iter() + .map(|suggestion| suggestion.to_string()), + ); + Ok(result) + } +} -- cgit v1.3.1-4-g156e