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) --- .../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 ++++ 16 files changed, 4906 insertions(+) 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 (limited to 'crates/shirabe-symfony-console/src/helper') 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 + } +} -- cgit v1.3.1-4-g156e