diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-12 01:01:35 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-12 01:01:43 +0900 |
| commit | 1e44f5723e4c0e0903d00a61f254901e612fe5e1 (patch) | |
| tree | 9c2e1eec364bbf6418a4072ee7767b04c3df0297 /crates/shirabe-external-packages/src/symfony/console/helper | |
| parent | ddf0a624145b618c05c2ee47414545b99b685f37 (diff) | |
| download | php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.gz php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.zst php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.zip | |
feat(symfony-console): port Symfony Console and make the workspace compile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-external-packages/src/symfony/console/helper')
17 files changed, 4856 insertions, 81 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/debug_formatter_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/debug_formatter_helper.rs new file mode 100644 index 0000000..26ad169 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/debug_formatter_helper.rs @@ -0,0 +1,194 @@ +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use indexmap::IndexMap; +use std::cell::RefCell; +use std::rc::Rc; + +const COLORS: [&str; 9] = [ + "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "default", +]; + +/// Helps outputting debug information when running an external program from a command. +/// +/// An external program can be a Process, an HTTP request, or anything else. +#[derive(Debug)] +pub struct DebugFormatterHelper { + inner: Helper, + started: IndexMap<String, DebugFormatterSession>, + count: i64, +} + +/// Per-id session state. PHP stores this as `['border' => int, 'out' => true, 'err' => true]` +/// where presence of `out`/`err` keys is tested via `isset` and removed via `unset`. +#[derive(Debug, Default)] +struct DebugFormatterSession { + border: i64, + out: bool, + err: bool, +} + +impl Default for DebugFormatterHelper { + fn default() -> Self { + Self { + inner: Helper::default(), + started: IndexMap::new(), + count: -1, + } + } +} + +impl DebugFormatterHelper { + /// Starts a debug formatting session. + pub fn start(&mut self, id: &str, message: &str, prefix: &str) -> String { + self.count += 1; + self.started.insert( + id.to_string(), + DebugFormatterSession { + border: self.count % COLORS.len() as i64, + out: false, + err: false, + }, + ); + + shirabe_php_shim::sprintf( + "%s<bg=blue;fg=white> %s </> <fg=blue>%s</>\n", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ) + } + + /// Adds progress to a formatting session. + pub fn progress( + &mut self, + id: &str, + buffer: &str, + error: bool, + prefix: &str, + error_prefix: &str, + ) -> String { + let mut message = String::new(); + + if error { + if self.started[id].out { + message.push('\n'); + self.started.get_mut(id).unwrap().out = false; + } + if !self.started[id].err { + message.push_str(&shirabe_php_shim::sprintf( + "%s<bg=red;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(error_prefix.to_string()), + ], + )); + self.started.get_mut(id).unwrap().err = true; + } + + message.push_str(&shirabe_php_shim::str_replace( + "\n", + &shirabe_php_shim::sprintf( + "\n%s<bg=red;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(error_prefix.to_string()), + ], + ), + buffer, + )); + } else { + if self.started[id].err { + message.push('\n'); + self.started.get_mut(id).unwrap().err = false; + } + if !self.started[id].out { + message.push_str(&shirabe_php_shim::sprintf( + "%s<bg=green;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + ], + )); + self.started.get_mut(id).unwrap().out = true; + } + + message.push_str(&shirabe_php_shim::str_replace( + "\n", + &shirabe_php_shim::sprintf( + "\n%s<bg=green;fg=white> %s </> ", + &[ + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + ], + ), + buffer, + )); + } + + message + } + + /// Stops a formatting session. + pub fn stop(&mut self, id: &str, message: &str, successful: bool, prefix: &str) -> String { + let trailing_eol = if self.started[id].out || self.started[id].err { + "\n" + } else { + "" + }; + + if successful { + return shirabe_php_shim::sprintf( + "%s%s<bg=green;fg=white> %s </> <fg=green>%s</>\n", + &[ + shirabe_php_shim::PhpMixed::String(trailing_eol.to_string()), + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ); + } + + let message = shirabe_php_shim::sprintf( + "%s%s<bg=red;fg=white> %s </> <fg=red>%s</>\n", + &[ + shirabe_php_shim::PhpMixed::String(trailing_eol.to_string()), + shirabe_php_shim::PhpMixed::String(self.get_border(id)), + shirabe_php_shim::PhpMixed::String(prefix.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ); + + if let Some(session) = self.started.get_mut(id) { + session.out = false; + session.err = false; + } + + message + } + + fn get_border(&self, id: &str) -> String { + shirabe_php_shim::sprintf( + "<bg=%s> </>", + &[shirabe_php_shim::PhpMixed::String( + COLORS[self.started[id].border as usize].to_string(), + )], + ) + } +} + +impl HelperInterface for DebugFormatterHelper { + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "debug_formatter".to_string() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/descriptor_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/descriptor_helper.rs new file mode 100644 index 0000000..64c166c --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/descriptor_helper.rs @@ -0,0 +1,135 @@ +use crate::symfony::console::descriptor::descriptor_interface::DescriptorInterface; +use crate::symfony::console::descriptor::json_descriptor::JsonDescriptor; +use crate::symfony::console::descriptor::markdown_descriptor::MarkdownDescriptor; +use crate::symfony::console::descriptor::text_descriptor::TextDescriptor; +use crate::symfony::console::descriptor::xml_descriptor::XmlDescriptor; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; +use std::cell::RefCell; +use std::rc::Rc; + +/// This class adds helper method to describe objects in various formats. +#[derive(Default)] +pub struct DescriptorHelper { + inner: Helper, + /// @var DescriptorInterface[] + descriptors: IndexMap<String, Box<dyn DescriptorInterface>>, +} + +// `DescriptorInterface` does not require `Debug`, so the derive cannot see +// through the trait object; provide a minimal manual impl. +impl std::fmt::Debug for DescriptorHelper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DescriptorHelper") + .field("inner", &self.inner) + .field("descriptors", &self.descriptors.keys().collect::<Vec<_>>()) + .finish() + } +} + +impl DescriptorHelper { + pub fn new() -> Self { + let mut this = Self { + inner: Helper::default(), + descriptors: IndexMap::new(), + }; + // The XML/JSON/Markdown descriptors do not yet expose a constructor + // (no `Default`/`new`); their construction is deferred until those + // descriptor types provide one. + let xml: Box<dyn DescriptorInterface> = todo!(); + let json: Box<dyn DescriptorInterface> = todo!(); + let md: Box<dyn DescriptorInterface> = todo!(); + this.register("txt", Box::new(TextDescriptor::default())) + .register("xml", xml) + .register("json", json) + .register("md", md); + this + } + + /// Describes an object if supported. + /// + /// Available options are: + /// * format: string, the output format name + /// * raw_text: boolean, sets output type as raw + /// + /// @throws InvalidArgumentException when the given format is not supported + pub fn describe2( + &self, + output: &dyn OutputInterface, + object: Option<shirabe_php_shim::PhpMixed>, + options: IndexMap<String, shirabe_php_shim::PhpMixed>, + ) -> Result<(), InvalidArgumentException> { + let mut merged: IndexMap<String, shirabe_php_shim::PhpMixed> = IndexMap::new(); + merged.insert( + "raw_text".to_string(), + shirabe_php_shim::PhpMixed::Bool(false), + ); + merged.insert( + "format".to_string(), + shirabe_php_shim::PhpMixed::String("txt".to_string()), + ); + for (key, value) in options { + merged.insert(key, value); + } + let options = merged; + + let format = match &options["format"] { + shirabe_php_shim::PhpMixed::String(format) => format.clone(), + _ => String::new(), + }; + + if !self.descriptors.contains_key(&format) { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Unsupported format \"%s\".", + &[shirabe_php_shim::PhpMixed::String(format.clone())], + ), + code: 0, + }, + )); + } + + let _ = (&self.descriptors[&format], output, object, options); + // `DescriptorInterface::describe` takes an owned + // `Rc<RefCell<dyn OutputInterface>>` and `&mut self`, while the helper + // only holds a shared `&dyn OutputInterface` and `&self`. Reconciling + // this ownership/mutability gap is a Phase C decision. + todo!("dispatch to DescriptorInterface::describe (Phase C ownership)"); + } + + /// Registers a descriptor. + /// + /// @return $this + pub fn register( + &mut self, + format: &str, + descriptor: Box<dyn DescriptorInterface>, + ) -> &mut Self { + self.descriptors.insert(format.to_string(), descriptor); + + self + } + + pub fn get_formats(&self) -> Vec<String> { + self.descriptors.keys().cloned().collect() + } +} + +impl HelperInterface for DescriptorHelper { + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "descriptor".to_string() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs index e55dbce..ef33f93 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/formatter_helper.rs @@ -1,20 +1,113 @@ -use crate::symfony::console::helper::HelperInterface; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use std::cell::RefCell; +use std::rc::Rc; -#[derive(Debug)] -pub struct FormatterHelper; +/// The Formatter class provides helpers to format messages. +#[derive(Debug, Default)] +pub struct FormatterHelper { + inner: Helper, +} impl FormatterHelper { - pub fn format_section(&self, _section: &str, _message: &str, _style: &str) -> String { - todo!() + /// Formats a message within a section. + pub fn format_section(&self, section: &str, message: &str, style: &str) -> String { + shirabe_php_shim::sprintf( + "<%s>[%s]</%s> %s", + &[ + shirabe_php_shim::PhpMixed::String(style.to_string()), + shirabe_php_shim::PhpMixed::String(section.to_string()), + shirabe_php_shim::PhpMixed::String(style.to_string()), + shirabe_php_shim::PhpMixed::String(message.to_string()), + ], + ) } - pub fn format_block(&self, _messages: &[&str], _style: &str, _large: bool) -> String { - todo!() + /// Formats a message as a block of text. + /// + /// @param string|array $messages The message to write in the block + pub fn format_block(&self, messages: FormatBlockMessages, style: &str, large: bool) -> String { + let messages = match messages { + FormatBlockMessages::String(message) => vec![message], + FormatBlockMessages::Array(messages) => messages, + }; + + let mut len: i64 = 0; + let mut lines: Vec<String> = Vec::new(); + for message in &messages { + let message = OutputFormatter::escape(message).unwrap(); + lines.push(shirabe_php_shim::sprintf( + if large { " %s " } else { " %s " }, + &[shirabe_php_shim::PhpMixed::String(message.clone())], + )); + len = std::cmp::max(Helper::width(&message) + (if large { 4 } else { 2 }), len); + } + + let mut messages: Vec<String> = if large { + vec![shirabe_php_shim::str_repeat(" ", len as usize)] + } else { + vec![] + }; + let mut i = 0; + while i < lines.len() { + messages.push(format!( + "{}{}", + lines[i], + shirabe_php_shim::str_repeat(" ", (len - Helper::width(&lines[i])) as usize) + )); + i += 1; + } + if large { + messages.push(shirabe_php_shim::str_repeat(" ", len as usize)); + } + + let mut i = 0; + while i < messages.len() { + messages[i] = shirabe_php_shim::sprintf( + "<%s>%s</%s>", + &[ + shirabe_php_shim::PhpMixed::String(style.to_string()), + shirabe_php_shim::PhpMixed::String(messages[i].clone()), + shirabe_php_shim::PhpMixed::String(style.to_string()), + ], + ); + i += 1; + } + + messages.join("\n") + } + + /// Truncates a message to the given length. + pub fn truncate(&self, message: &str, length: i64, suffix: &str) -> String { + let computed_length = length - Helper::width(suffix); + + if computed_length > Helper::width(message) { + return message.to_string(); + } + + format!("{}{}", Helper::substr(message, 0, Some(length)), suffix) } } impl HelperInterface for FormatterHelper { - fn as_any(&self) -> &dyn std::any::Any { - self + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "formatter".to_string() } } + +/// `formatBlock` accepts either a single string or an array of strings. +#[derive(Debug)] +pub enum FormatBlockMessages { + String(String), + Array(Vec<String>), +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs index 881aabd..b599f11 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/helper.rs @@ -1,3 +1,171 @@ -pub trait Helper { - fn get_name(&self) -> String; +use crate::symfony::console::formatter::output_formatter_interface::OutputFormatterInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::string::unicode_string::UnicodeString; +use std::cell::RefCell; +use std::rc::Rc; + +/// Helper is the base class for all helper classes. +#[derive(Debug, Default)] +pub struct Helper { + pub(crate) helper_set: Option<Rc<RefCell<HelperSet>>>, +} + +impl Helper { + pub fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.helper_set = helper_set; + } + + pub fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.helper_set.clone() + } + + /// Returns the length of a string, using mb_strwidth if it is available. + /// + /// @deprecated since Symfony 5.3 + pub fn strlen(string: &str) -> i64 { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.3", + "Method \"%s()\" is deprecated and will be removed in Symfony 6.0. Use Helper::width() or Helper::length() instead.", + "Helper::strlen", + ); + + Self::width(string) + } + + /// Returns the width of a string, using mb_strwidth if it is available. + /// The width is how many characters positions the string will use. + pub fn width(string: &str) -> i64 { + if shirabe_php_shim::preg_match("//u", string, &mut Vec::new()) != 0 { + return UnicodeString::new(string).width(false); + } + + let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true); + let encoding = match encoding { + Some(encoding) => encoding, + None => return shirabe_php_shim::strlen(string), + }; + + shirabe_php_shim::mb_strwidth(string, Some(&encoding)) + } + + /// Returns the length of a string, using mb_strlen if it is available. + /// The length is related to how many bytes the string will use. + pub fn length(string: &str) -> i64 { + if shirabe_php_shim::preg_match("//u", string, &mut Vec::new()) != 0 { + return UnicodeString::new(string).length(); + } + + let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true); + let encoding = match encoding { + Some(encoding) => encoding, + None => return shirabe_php_shim::strlen(string), + }; + + shirabe_php_shim::mb_strlen(string, &encoding) + } + + /// Returns the subset of a string, using mb_substr if it is available. + pub fn substr(string: &str, from: i64, length: Option<i64>) -> String { + let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true); + let encoding = match encoding { + Some(encoding) => encoding, + None => return shirabe_php_shim::substr(string, from, length), + }; + + shirabe_php_shim::mb_substr(string, from, length, Some(&encoding)) + } + + pub fn format_time(secs: f64) -> Option<String> { + // [threshold, label, divisor?] + let time_formats: [(f64, &str, Option<f64>); 9] = [ + (0.0, "< 1 sec", None), + (1.0, "1 sec", None), + (2.0, "secs", Some(1.0)), + (60.0, "1 min", None), + (120.0, "mins", Some(60.0)), + (3600.0, "1 hr", None), + (7200.0, "hrs", Some(3600.0)), + (86400.0, "1 day", None), + (172800.0, "days", Some(86400.0)), + ]; + + for (index, format) in time_formats.iter().enumerate() { + if secs >= format.0 { + if (index + 1 < time_formats.len() && secs < time_formats[index + 1].0) + || index == time_formats.len() - 1 + { + match format.2 { + None => return Some(format.1.to_string()), + Some(divisor) => { + return Some(format!("{} {}", (secs / divisor).floor(), format.1)); + } + } + } + } + } + + None + } + + pub fn format_memory(memory: i64) -> String { + if memory >= 1024 * 1024 * 1024 { + return shirabe_php_shim::sprintf( + "%.1f GiB", + &[shirabe_php_shim::PhpMixed::Float( + memory as f64 / 1024.0 / 1024.0 / 1024.0, + )], + ); + } + + if memory >= 1024 * 1024 { + return shirabe_php_shim::sprintf( + "%.1f MiB", + &[shirabe_php_shim::PhpMixed::Float( + memory as f64 / 1024.0 / 1024.0, + )], + ); + } + + if memory >= 1024 { + return shirabe_php_shim::sprintf( + "%d KiB", + &[shirabe_php_shim::PhpMixed::Int(memory / 1024)], + ); + } + + shirabe_php_shim::sprintf("%d B", &[shirabe_php_shim::PhpMixed::Int(memory)]) + } + + /// @deprecated since Symfony 5.3 + pub fn strlen_without_decoration( + formatter: &mut dyn OutputFormatterInterface, + string: &str, + ) -> i64 { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.3", + "Method \"%s()\" is deprecated and will be removed in Symfony 6.0. Use Helper::removeDecoration() instead.", + "Helper::strlenWithoutDecoration", + ); + + Self::width(&Self::remove_decoration(formatter, string)) + } + + pub fn remove_decoration(formatter: &mut dyn OutputFormatterInterface, string: &str) -> String { + let is_decorated = formatter.is_decorated(); + formatter.set_decorated(false); + // remove <...> formatting + let string = formatter.format(Some(string)).unwrap().unwrap_or_default(); + // remove already formatted characters + let string = + shirabe_php_shim::preg_replace("/\u{1b}\\[[^m]*m/", "", &string).unwrap_or_default(); + // remove terminal hyperlinks + let string = + shirabe_php_shim::preg_replace("/\u{1b}]8;[^;]*;[^\u{1b}]*\u{1b}\\\\/", "", &string) + .unwrap_or_default(); + formatter.set_decorated(is_decorated); + + string + } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs b/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs index cf60bf0..f7481fd 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/helper_interface.rs @@ -1,17 +1,15 @@ -use crate::symfony::console::helper::HelperSet; +use crate::symfony::console::helper::helper_set::HelperSet; +use std::cell::RefCell; +use std::rc::Rc; -pub trait HelperInterface: std::fmt::Debug { - fn set_helper_set(&mut self, _helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>) { - todo!() - } +/// HelperInterface is the interface all helpers must implement. +pub trait HelperInterface: std::fmt::Debug + shirabe_php_shim::AsAny { + /// Sets the helper set associated with this helper. + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>); - fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::RefCell<HelperSet>>> { - todo!() - } + /// Gets the helper set associated with this helper. + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>>; - fn get_name(&self) -> String { - todo!() - } - - fn as_any(&self) -> &dyn std::any::Any; + /// Returns the canonical name of this helper. + fn get_name(&self) -> String; } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs b/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs index e38dbf3..a7ef820 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/helper_set.rs @@ -1,27 +1,115 @@ -use crate::symfony::console::helper::HelperInterface; +use crate::symfony::console::command::command::Command; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::helper_interface::HelperInterface; use indexmap::IndexMap; use std::cell::RefCell; use std::rc::Rc; -#[derive(Debug)] +/// HelperSet represents a set of helpers to be used with a command. +/// +/// @implements \IteratorAggregate<string, Helper> +#[derive(Debug, Default, Clone)] pub struct HelperSet { helpers: IndexMap<String, Rc<RefCell<dyn HelperInterface>>>, + command: Option<Rc<RefCell<dyn Command>>>, } impl HelperSet { - pub fn new(_helpers: Vec<Rc<RefCell<dyn HelperInterface>>>) -> Self { - todo!() + /// @param Helper[] $helpers An array of helper + pub fn new( + this: &Rc<RefCell<HelperSet>>, + helpers: IndexMap<HelperSetKey, Rc<RefCell<dyn HelperInterface>>>, + ) { + for (alias, helper) in helpers { + let alias = match alias { + HelperSetKey::Int(_) => None, + HelperSetKey::String(alias) => Some(alias), + }; + Self::set(this, helper, alias.as_deref()); + } } - pub fn get<T: HelperInterface + 'static>(&self, _name: &str) -> Rc<RefCell<T>> { - todo!() + pub fn set( + this: &Rc<RefCell<HelperSet>>, + helper: Rc<RefCell<dyn HelperInterface>>, + alias: Option<&str>, + ) { + let name = helper.borrow().get_name(); + this.borrow_mut().helpers.insert(name, helper.clone()); + if let Some(alias) = alias { + this.borrow_mut() + .helpers + .insert(alias.to_string(), helper.clone()); + } + + helper.borrow_mut().set_helper_set(Some(this.clone())); + } + + /// Returns true if the helper if defined. + pub fn has(&self, name: &str) -> bool { + self.helpers.contains_key(name) + } + + /// Gets a helper value. + /// + /// @throws InvalidArgumentException if the helper is not defined + pub fn get( + &self, + name: &str, + ) -> Result<Rc<RefCell<dyn HelperInterface>>, InvalidArgumentException> { + if !self.has(name) { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The helper \"%s\" is not defined.", + &[shirabe_php_shim::PhpMixed::String(name.to_string())], + ), + code: 0, + }, + )); + } + + Ok(self.helpers[name].clone()) } - pub fn set(&mut self, _helper: Rc<RefCell<dyn HelperInterface>>, _alias: Option<&str>) { - todo!() + /// @deprecated since Symfony 5.4 + pub fn set_command(&mut self, command: Option<Rc<RefCell<dyn Command>>>) { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.4", + "Method \"%s()\" is deprecated.", + "HelperSet::setCommand", + ); + + self.command = command; + } + + /// Gets the command associated with this helper set. + /// + /// @deprecated since Symfony 5.4 + pub fn get_command(&self) -> Option<Rc<RefCell<dyn Command>>> { + shirabe_php_shim::trigger_deprecation( + "symfony/console", + "5.4", + "Method \"%s()\" is deprecated.", + "HelperSet::getCommand", + ); + + self.command.clone() } - pub fn has(&self, _name: &str) -> bool { - todo!() + /// @return \Traversable<string, Helper> + pub fn get_iterator( + &self, + ) -> impl Iterator<Item = (&String, &Rc<RefCell<dyn HelperInterface>>)> { + self.helpers.iter() } } + +/// PHP array keys are either integers or strings; the HelperSet constructor +/// distinguishes them via `\is_int($alias)`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum HelperSetKey { + Int(i64), + String(String), +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs b/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs index 191fb6e..8aed672 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/mod.rs @@ -1,17 +1,33 @@ +pub mod debug_formatter_helper; +pub mod descriptor_helper; pub mod formatter_helper; pub mod helper; pub mod helper_interface; pub mod helper_set; +pub mod process_helper; pub mod progress_bar; pub mod question_helper; +pub mod symfony_question_helper; pub mod table; +pub mod table_cell; +pub mod table_cell_style; +pub mod table_rows; pub mod table_separator; +pub mod table_style; +pub use debug_formatter_helper::*; +pub use descriptor_helper::*; pub use formatter_helper::*; pub use helper::*; pub use helper_interface::*; pub use helper_set::*; +pub use process_helper::*; pub use progress_bar::*; pub use question_helper::*; +pub use symfony_question_helper::*; pub use table::*; +pub use table_cell::*; +pub use table_cell_style::*; +pub use table_rows::*; pub use table_separator::*; +pub use table_style::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/process_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/process_helper.rs new file mode 100644 index 0000000..7652cac --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/process_helper.rs @@ -0,0 +1,306 @@ +use crate::symfony::console::helper::debug_formatter_helper::DebugFormatterHelper; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::output::console_output_interface::ConsoleOutputInterface; +use crate::symfony::console::output::output_interface::{self, OutputInterface}; +use crate::symfony::process::exception::process_failed_exception::ProcessFailedException; +use crate::symfony::process::process::Process; +use std::cell::RefCell; +use std::rc::Rc; + +/// The ProcessHelper class provides helpers to run external processes. +/// +/// @final +#[derive(Debug, Default)] +pub struct ProcessHelper { + inner: Helper, +} + +/// `$cmd` is either a `Process` instance or an array whose first element is a +/// binary path (string) or a `Process`, followed by extra environment entries. +#[derive(Debug)] +pub enum ProcessHelperCmd { + Process(Process), + Array(Vec<ProcessHelperCmdElement>), +} + +#[derive(Debug)] +pub enum ProcessHelperCmdElement { + String(String), + Process(Process), +} + +impl ProcessHelper { + /// Runs an external process. + /// + /// @param array|Process $cmd An instance of Process or an array of the command and arguments + /// @param callable|null $callback A PHP callback to run whenever there is some + /// output available on STDOUT or STDERR + pub fn run( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + cmd: ProcessHelperCmd, + error: Option<&str>, + callback: Option<Box<dyn FnMut(&str, &str)>>, + verbosity: i64, + ) -> anyhow::Result<Process> { + // `class_exists(Process::class)` guards against the optional symfony/process + // component being absent; in this port the component is always available. + + // PHP: `if ($output instanceof ConsoleOutputInterface) { $output = + // $output->getErrorOutput(); }`. Downcasting a shared `dyn + // OutputInterface` to `dyn ConsoleOutputInterface` is unresolved; + // deferred to Phase C. + let output: Rc<RefCell<dyn OutputInterface>> = + todo!("$output instanceof ConsoleOutputInterface redirect to error output"); + + let formatter: Rc<RefCell<dyn HelperInterface>> = self + .get_helper_set() + .unwrap() + .borrow() + .get("debug_formatter") + .unwrap(); + + // Normalize $cmd: a single Process becomes a one-element array. + let mut cmd = match cmd { + ProcessHelperCmd::Process(process) => { + vec![ProcessHelperCmdElement::Process(process)] + } + ProcessHelperCmd::Array(cmd) => cmd, + }; + + // `!\is_array($cmd)` cannot happen given the enum, so the TypeError branch + // is unreachable here. + + let mut process: Process; + match cmd.first() { + Some(ProcessHelperCmdElement::String(_)) => { + let command: Vec<String> = cmd + .iter() + .map(|element| match element { + ProcessHelperCmdElement::String(s) => s.clone(), + ProcessHelperCmdElement::Process(_) => unreachable!(), + }) + .collect(); + process = Process::new(command, None, None, None, Some(60.0)); + cmd = vec![]; + } + Some(ProcessHelperCmdElement::Process(_)) => { + let first = cmd.remove(0); + process = match first { + ProcessHelperCmdElement::Process(process) => process, + ProcessHelperCmdElement::String(_) => unreachable!(), + }; + } + None => { + anyhow::bail!(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Invalid command provided to \"%s()\": the command should be an array whose first element is either the path to the binary to run or a \"Process\" object.", + &[shirabe_php_shim::PhpMixed::String( + "ProcessHelper::run".to_string() + )], + ), + code: 0, + }); + } + } + + if verbosity <= output.borrow().get_verbosity() { + let started = Self::formatter_start( + &formatter, + &shirabe_php_shim::spl_object_hash_process(&process), + &self.escape_string(&process.get_command_line()), + ); + output + .borrow() + .write(&[started], false, output_interface::OUTPUT_NORMAL); + } + + let callback = if output.borrow().is_debug() { + Some(self.wrap_callback(output.clone(), &process, callback)) + } else { + callback + }; + + // PHP passes the remaining `$cmd` array as the `$env` argument to Process::run. + process.run(callback); + + if verbosity <= output.borrow().get_verbosity() { + let message = if process.is_successful() { + "Command ran successfully".to_string() + } else { + shirabe_php_shim::sprintf( + "%s Command did not run successfully", + &[match process.get_exit_code() { + Some(code) => shirabe_php_shim::PhpMixed::Int(code), + None => shirabe_php_shim::PhpMixed::Null, + }], + ) + }; + let stopped = Self::formatter_stop( + &formatter, + &shirabe_php_shim::spl_object_hash_process(&process), + &message, + process.is_successful(), + ); + output + .borrow() + .write(&[stopped], false, output_interface::OUTPUT_NORMAL); + } + + if !process.is_successful() && error.is_some() { + output.borrow().writeln( + &[shirabe_php_shim::sprintf( + "<error>%s</error>", + &[shirabe_php_shim::PhpMixed::String( + self.escape_string(error.unwrap()), + )], + )], + output_interface::OUTPUT_NORMAL, + ); + } + + Ok(process) + } + + /// Runs the process. + /// + /// This is identical to run() except that an exception is thrown if the process + /// exits with a non-zero exit code. + /// + /// @param array|Process $cmd An instance of Process or a command to run + /// @param callable|null $callback A PHP callback to run whenever there is some + /// output available on STDOUT or STDERR + /// + /// @throws ProcessFailedException + /// + /// @see run() + pub fn must_run( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + cmd: ProcessHelperCmd, + error: Option<&str>, + callback: Option<Box<dyn FnMut(&str, &str)>>, + ) -> anyhow::Result<Process> { + let process = self.run( + output, + cmd, + error, + callback, + output_interface::VERBOSITY_VERY_VERBOSE, + )?; + + if !process.is_successful() { + anyhow::bail!(ProcessFailedException::new(&process)); + } + + Ok(process) + } + + /// Wraps a Process callback to add debugging output. + pub fn wrap_callback( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + process: &Process, + mut callback: Option<Box<dyn FnMut(&str, &str)>>, + ) -> Box<dyn FnMut(&str, &str)> { + // PHP: `if ($output instanceof ConsoleOutputInterface) { $output = + // $output->getErrorOutput(); }`. Downcasting a shared `dyn + // OutputInterface` to `dyn ConsoleOutputInterface` is unresolved; + // deferred to Phase C. + let output: Rc<RefCell<dyn OutputInterface>> = + todo!("$output instanceof ConsoleOutputInterface redirect to error output"); + + let formatter: Rc<RefCell<dyn HelperInterface>> = self + .get_helper_set() + .unwrap() + .borrow() + .get("debug_formatter") + .unwrap(); + + let object_hash = shirabe_php_shim::spl_object_hash_process(process); + + Box::new(move |r#type: &str, buffer: &str| { + let progressed = Self::formatter_progress( + &formatter, + &object_hash, + &Self::escape_string_static(buffer), + Process::ERR == r#type, + ); + output + .borrow() + .write(&[progressed], false, output_interface::OUTPUT_NORMAL); + + if let Some(callback) = callback.as_mut() { + callback(r#type, buffer); + } + }) + } + + fn escape_string(&self, str: &str) -> String { + shirabe_php_shim::str_replace("<", "\\<", str) + } + + fn escape_string_static(str: &str) -> String { + shirabe_php_shim::str_replace("<", "\\<", str) + } + + /// PHP fetches `debug_formatter` from the HelperSet as a `HelperInterface` and + /// dynamically dispatches `start`/`stop`/`progress`, which are concrete + /// `DebugFormatterHelper` methods not present on the interface. Resolving the + /// dynamic helper handle back to the concrete `DebugFormatterHelper` is a + /// downcast that requires a Phase C decision (e.g. an `as_any` on + /// `HelperInterface`); deferred here. + fn debug_formatter( + _formatter: &Rc<RefCell<dyn HelperInterface>>, + ) -> Rc<RefCell<DebugFormatterHelper>> { + todo!("downcast HelperInterface handle to DebugFormatterHelper (Phase C)") + } + + fn formatter_start( + formatter: &Rc<RefCell<dyn HelperInterface>>, + id: &str, + message: &str, + ) -> String { + Self::debug_formatter(formatter) + .borrow_mut() + .start(id, message, "RUN") + } + + fn formatter_stop( + formatter: &Rc<RefCell<dyn HelperInterface>>, + id: &str, + message: &str, + successful: bool, + ) -> String { + Self::debug_formatter(formatter) + .borrow_mut() + .stop(id, message, successful, "RES") + } + + fn formatter_progress( + formatter: &Rc<RefCell<dyn HelperInterface>>, + id: &str, + buffer: &str, + error: bool, + ) -> String { + Self::debug_formatter(formatter) + .borrow_mut() + .progress(id, buffer, error, "OUT", "ERR") + } +} + +impl HelperInterface for ProcessHelper { + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + "process".to_string() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs b/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs index e2a728f..bd6fe86 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/progress_bar.rs @@ -1,38 +1,826 @@ +use crate::symfony::console::cursor::Cursor; +use crate::symfony::console::exception::logic_exception::LogicException; +use crate::symfony::console::helper::helper::Helper; use crate::symfony::console::output::OutputInterface; +use crate::symfony::console::output::output_interface; +use crate::symfony::console::terminal::Terminal; +use indexmap::IndexMap; +use std::cell::RefCell; +use std::rc::Rc; +pub const FORMAT_VERBOSE: &str = "verbose"; +pub const FORMAT_VERY_VERBOSE: &str = "very_verbose"; +pub const FORMAT_DEBUG: &str = "debug"; +pub const FORMAT_NORMAL: &str = "normal"; + +const FORMAT_VERBOSE_NOMAX: &str = "verbose_nomax"; +const FORMAT_VERY_VERBOSE_NOMAX: &str = "very_verbose_nomax"; +const FORMAT_DEBUG_NOMAX: &str = "debug_nomax"; +const FORMAT_NORMAL_NOMAX: &str = "normal_nomax"; + +/// A placeholder formatter callable, receiving the bar and the output. +pub type PlaceholderFormatter = Box< + dyn Fn( + &ProgressBar, + &Rc<RefCell<dyn OutputInterface>>, + ) -> anyhow::Result<Result<shirabe_php_shim::PhpMixed, LogicException>>, +>; + +/// The ProgressBar provides helpers to display progress output. #[derive(Debug)] -pub struct ProgressBar; +pub struct ProgressBar { + bar_width: i64, + bar_char: Option<String>, + empty_bar_char: String, + progress_char: String, + format: Option<String>, + internal_format: Option<String>, + redraw_freq: Option<i64>, + write_count: i64, + last_write_time: f64, + min_seconds_between_redraws: f64, + max_seconds_between_redraws: f64, + output: Rc<RefCell<dyn OutputInterface>>, + step: i64, + max: i64, + start_time: i64, + step_width: i64, + percent: f64, + messages: IndexMap<String, String>, + overwrite: bool, + terminal: Terminal, + previous_message: Option<String>, + cursor: Cursor, +} + +thread_local! { + static FORMATTERS: RefCell<Option<IndexMap<String, PlaceholderFormatter>>> = const { RefCell::new(None) }; + static FORMATS: RefCell<Option<IndexMap<String, String>>> = const { RefCell::new(None) }; +} impl ProgressBar { - pub fn new(_output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, _max: i64) -> Self { - todo!() + /// `$max` Maximum steps (0 if unknown) + pub fn new( + output: Rc<RefCell<dyn OutputInterface>>, + max: i64, + min_seconds_between_redraws: f64, + ) -> Self { + let output = if todo!("$output instanceof ConsoleOutputInterface") { + todo!("$output = $output->getErrorOutput();") + } else { + output + }; + + let mut this = Self { + bar_width: 28, + bar_char: None, + empty_bar_char: "-".to_string(), + progress_char: ">".to_string(), + format: None, + internal_format: None, + redraw_freq: Some(1), + write_count: 0, + last_write_time: 0.0, + min_seconds_between_redraws: 0.0, + max_seconds_between_redraws: 1.0, + output: output.clone(), + step: 0, + max: 0, + start_time: 0, + step_width: 0, + percent: 0.0, + messages: IndexMap::new(), + overwrite: true, + terminal: Terminal::new(), + previous_message: None, + cursor: Cursor::new(output.clone(), None), + }; + + this.set_max_steps(max); + + if 0.0 < min_seconds_between_redraws { + this.redraw_freq = None; + this.min_seconds_between_redraws = min_seconds_between_redraws; + } + + if !this.output.borrow().is_decorated() { + // disable overwrite when output does not support ANSI codes. + this.overwrite = false; + + // set a reasonable redraw frequency so output isn't flooded + this.redraw_freq = None; + } + + this.start_time = shirabe_php_shim::time(); + + this } - pub fn start(&mut self, _max: Option<i64>) { - todo!() + /// Sets a placeholder formatter for a given name. + /// + /// This method also allow you to override an existing placeholder. + /// + /// `$name` The placeholder name (including the delimiter char like %) + /// `$callable` A PHP callable + pub fn set_placeholder_formatter_definition(name: &str, callable: PlaceholderFormatter) { + FORMATTERS.with(|formatters| { + let mut formatters = formatters.borrow_mut(); + if formatters.is_none() { + *formatters = Some(Self::init_placeholder_formatters()); + } + + formatters + .as_mut() + .unwrap() + .insert(name.to_string(), callable); + }); } - pub fn advance(&mut self, _step: i64) { - todo!() + /// Gets the placeholder formatter for a given name. + /// + /// `$name` The placeholder name (including the delimiter char like %) + pub fn get_placeholder_formatter_definition(name: &str) -> Option<()> { + // Note: the returned callable cannot be cloned out of the thread-local + // map; call sites invoke the formatter via the map directly. + FORMATTERS.with(|formatters| { + let mut formatters = formatters.borrow_mut(); + if formatters.is_none() { + *formatters = Some(Self::init_placeholder_formatters()); + } + + formatters.as_ref().unwrap().get(name).map(|_| ()) + }) } - pub fn finish(&mut self) { - todo!() + /// Sets a format for a given name. + /// + /// This method also allow you to override an existing format. + /// + /// `$name` The format name + /// `$format` A format string + pub fn set_format_definition(name: &str, format: &str) { + FORMATS.with(|formats| { + let mut formats = formats.borrow_mut(); + if formats.is_none() { + *formats = Some(Self::init_formats()); + } + + formats + .as_mut() + .unwrap() + .insert(name.to_string(), format.to_string()); + }); } - pub fn set_format(&mut self, _format: &str) { - todo!() + /// Gets the format for a given name. + /// + /// `$name` The format name + pub fn get_format_definition(name: &str) -> Option<String> { + FORMATS.with(|formats| { + let mut formats = formats.borrow_mut(); + if formats.is_none() { + *formats = Some(Self::init_formats()); + } + + formats.as_ref().unwrap().get(name).cloned() + }) } - pub fn get_progress(&self) -> i64 { - todo!() + /// Associates a text with a named placeholder. + /// + /// The text is displayed when the progress bar is rendered but only + /// when the corresponding placeholder is part of the custom format line + /// (by wrapping the name with %). + /// + /// `$message` The text to associate with the placeholder + /// `$name` The name of the placeholder + pub fn set_message(&mut self, message: &str, name: &str) { + self.messages.insert(name.to_string(), message.to_string()); + } + + pub fn get_message(&self, name: &str) -> Option<String> { + self.messages.get(name).cloned() + } + + pub fn get_start_time(&self) -> i64 { + self.start_time } pub fn get_max_steps(&self) -> i64 { - todo!() + self.max + } + + pub fn get_progress(&self) -> i64 { + self.step + } + + fn get_step_width(&self) -> i64 { + self.step_width + } + + pub fn get_progress_percent(&self) -> f64 { + self.percent + } + + pub fn get_bar_offset(&self) -> f64 { + shirabe_php_shim::floor(if self.max != 0 { + self.percent * self.bar_width as f64 + } else if self.redraw_freq.is_none() { + ((shirabe_php_shim::min(5, self.bar_width / 15) * self.write_count) % self.bar_width) + as f64 + } else { + (self.step % self.bar_width) as f64 + }) + } + + pub fn get_estimated(&self) -> f64 { + if self.step == 0 { + return 0.0; + } + + shirabe_php_shim::round( + (shirabe_php_shim::time() - self.start_time) as f64 / self.step as f64 + * self.max as f64, + 0, + ) + } + + pub fn get_remaining(&self) -> f64 { + if self.step == 0 { + return 0.0; + } + + shirabe_php_shim::round( + (shirabe_php_shim::time() - self.start_time) as f64 / self.step as f64 + * (self.max - self.step) as f64, + 0, + ) + } + + pub fn set_bar_width(&mut self, size: i64) { + self.bar_width = shirabe_php_shim::max(1, size); + } + + pub fn get_bar_width(&self) -> i64 { + self.bar_width + } + + pub fn set_bar_character(&mut self, char: &str) { + self.bar_char = Some(char.to_string()); + } + + pub fn get_bar_character(&self) -> String { + match &self.bar_char { + Some(bar_char) => bar_char.clone(), + None => { + if self.max != 0 { + "=".to_string() + } else { + self.empty_bar_char.clone() + } + } + } + } + + pub fn set_empty_bar_character(&mut self, char: &str) { + self.empty_bar_char = char.to_string(); + } + + pub fn get_empty_bar_character(&self) -> String { + self.empty_bar_char.clone() + } + + pub fn set_progress_character(&mut self, char: &str) { + self.progress_char = char.to_string(); + } + + pub fn get_progress_character(&self) -> String { + self.progress_char.clone() + } + + pub fn set_format(&mut self, format: &str) { + self.format = None; + self.internal_format = Some(format.to_string()); + } + + /// Sets the redraw frequency. + /// + /// `$freq` The frequency in steps + pub fn set_redraw_frequency(&mut self, freq: Option<i64>) { + self.redraw_freq = freq.map(|freq| shirabe_php_shim::max(1, freq)); + } + + pub fn min_seconds_between_redraws(&mut self, seconds: f64) { + self.min_seconds_between_redraws = seconds; + } + + pub fn max_seconds_between_redraws(&mut self, seconds: f64) { + self.max_seconds_between_redraws = seconds; + } + + /// Returns an iterator that will automatically update the progress bar when iterated. + /// + /// `$max` Number of steps to complete the bar (0 if indeterminate), if null it will be + /// inferred from `$iterable` + pub fn iterate( + &mut self, + iterable: Vec<(shirabe_php_shim::PhpMixed, shirabe_php_shim::PhpMixed)>, + max: Option<i64>, + ) -> anyhow::Result<Vec<(shirabe_php_shim::PhpMixed, shirabe_php_shim::PhpMixed)>> { + self.start(Some(max.unwrap_or_else(|| { + // is_countable($iterable) ? \count($iterable) : 0 + iterable.len() as i64 + })))?; + + let mut yielded = Vec::new(); + for (key, value) in iterable { + yielded.push((key, value)); + + self.advance(1)?; + } + + self.finish()?; + + Ok(yielded) + } + + /// Starts the progress output. + /// + /// `$max` Number of steps to complete the bar (0 if indeterminate), null to leave unchanged + pub fn start(&mut self, max: Option<i64>) -> anyhow::Result<()> { + self.start_time = shirabe_php_shim::time(); + self.step = 0; + self.percent = 0.0; + + if let Some(max) = max { + self.set_max_steps(max); + } + + self.display() + } + + /// Advances the progress output X steps. + /// + /// `$step` Number of steps to advance + pub fn advance(&mut self, step: i64) -> anyhow::Result<()> { + self.set_progress(self.step + step) + } + + /// Sets whether to overwrite the progressbar, false for new line. + pub fn set_overwrite(&mut self, overwrite: bool) { + self.overwrite = overwrite; + } + + pub fn set_progress(&mut self, mut step: i64) -> anyhow::Result<()> { + if self.max != 0 && step > self.max { + self.max = step; + } else if step < 0 { + step = 0; + } + + let redraw_freq = match self.redraw_freq { + Some(redraw_freq) => redraw_freq as f64, + None => (if self.max != 0 { self.max } else { 10 }) as f64 / 10.0, + }; + let prev_period = (self.step as f64 / redraw_freq) as i64; + let curr_period = (step as f64 / redraw_freq) as i64; + self.step = step; + self.percent = if self.max != 0 { + self.step as f64 / self.max as f64 + } else { + 0.0 + }; + let time_interval = shirabe_php_shim::microtime(true) - self.last_write_time; + + // Draw regardless of other limits + if self.max == step { + self.display()?; + + return Ok(()); + } + + // Throttling + if time_interval < self.min_seconds_between_redraws { + return Ok(()); + } + + // Draw each step period, but not too late + if prev_period != curr_period || time_interval >= self.max_seconds_between_redraws { + self.display()?; + } + + Ok(()) + } + + pub fn set_max_steps(&mut self, max: i64) { + self.format = None; + self.max = shirabe_php_shim::max(0, max); + self.step_width = if self.max != 0 { + Helper::width(&self.max.to_string()) + } else { + 4 + }; + } + + /// Finishes the progress output. + pub fn finish(&mut self) -> anyhow::Result<()> { + if self.max == 0 { + self.max = self.step; + } + + if self.step == self.max && !self.overwrite { + // prevent double 100% output + return Ok(()); + } + + self.set_progress(self.max) + } + + /// Outputs the current progress string. + pub fn display(&mut self) -> anyhow::Result<()> { + if output_interface::VERBOSITY_QUIET == self.output.borrow().get_verbosity() { + return Ok(()); + } + + if self.format.is_none() { + let format = match &self.internal_format { + Some(internal_format) if !internal_format.is_empty() => internal_format.clone(), + _ => self.determine_best_format().to_string(), + }; + self.set_real_format(&format); + } + + let line = self.build_line()?; + self.overwrite(&line); + + Ok(()) + } + + /// Removes the progress bar from the current line. + /// + /// This is useful if you wish to write some output + /// while a progress bar is running. + /// Call display() to show the progress bar again. + pub fn clear(&mut self) -> anyhow::Result<()> { + if !self.overwrite { + return Ok(()); + } + + if self.format.is_none() { + let format = match &self.internal_format { + Some(internal_format) if !internal_format.is_empty() => internal_format.clone(), + _ => self.determine_best_format().to_string(), + }; + self.set_real_format(&format); + } + + self.overwrite(""); + + Ok(()) + } + + fn set_real_format(&mut self, format: &str) { + // try to use the _nomax variant if available + if self.max == 0 && Self::get_format_definition(&format!("{format}_nomax")).is_some() { + self.format = Self::get_format_definition(&format!("{format}_nomax")); + } else if Self::get_format_definition(format).is_some() { + self.format = Self::get_format_definition(format); + } else { + self.format = Some(format.to_string()); + } + } + + /// Overwrites a previous message to the output. + fn overwrite(&mut self, message: &str) { + if self.previous_message.as_deref() == Some(message) { + return; + } + + let original_message = message.to_string(); + let mut message = message.to_string(); + + if self.overwrite { + if let Some(previous_message) = self.previous_message.clone() { + if todo!("$this->output instanceof ConsoleSectionOutput") { + let message_lines = shirabe_php_shim::explode("\n", &previous_message); + let mut line_count = message_lines.len() as i64; + for message_line in &message_lines { + let message_line_length = Helper::width(&Helper::remove_decoration( + todo!("$this->output->getFormatter()"), + message_line, + )); + if message_line_length > self.terminal.get_width() { + line_count += shirabe_php_shim::floor( + message_line_length as f64 / self.terminal.get_width() as f64, + ) as i64; + } + } + todo!("$this->output->clear($lineCount); (ConsoleSectionOutput)"); + } else { + let line_count = shirabe_php_shim::substr_count(&previous_message, "\n"); + for _i in 0..line_count { + self.cursor.move_to_column(1); + self.cursor.clear_line(); + self.cursor.move_up(1); + } + + self.cursor.move_to_column(1); + self.cursor.clear_line(); + } + } + } else if self.step > 0 { + message = format!("{}{}", shirabe_php_shim::PHP_EOL, message); + } + + self.previous_message = Some(original_message); + self.last_write_time = shirabe_php_shim::microtime(true); + + self.output + .borrow() + .write(&[message], false, output_interface::OUTPUT_NORMAL); + self.write_count += 1; } - pub fn set_progress(&mut self, _step: i64) { - todo!() + fn determine_best_format(&self) -> &'static str { + match self.output.borrow().get_verbosity() { + // OutputInterface::VERBOSITY_QUIET: display is disabled anyway + output_interface::VERBOSITY_VERBOSE => { + if self.max != 0 { + FORMAT_VERBOSE + } else { + FORMAT_VERBOSE_NOMAX + } + } + output_interface::VERBOSITY_VERY_VERBOSE => { + if self.max != 0 { + FORMAT_VERY_VERBOSE + } else { + FORMAT_VERY_VERBOSE_NOMAX + } + } + output_interface::VERBOSITY_DEBUG => { + if self.max != 0 { + FORMAT_DEBUG + } else { + FORMAT_DEBUG_NOMAX + } + } + _ => { + if self.max != 0 { + FORMAT_NORMAL + } else { + FORMAT_NORMAL_NOMAX + } + } + } + } + + fn init_placeholder_formatters() -> IndexMap<String, PlaceholderFormatter> { + let mut formatters: IndexMap<String, PlaceholderFormatter> = IndexMap::new(); + + formatters.insert( + "bar".to_string(), + Box::new( + |bar: &ProgressBar, output: &Rc<RefCell<dyn OutputInterface>>| { + let complete_bars = bar.get_bar_offset(); + let mut display = shirabe_php_shim::str_repeat( + &bar.get_bar_character(), + complete_bars as usize, + ); + if complete_bars < bar.get_bar_width() as f64 { + let empty_bars = bar.get_bar_width() as f64 + - complete_bars + - Helper::length(&Helper::remove_decoration( + todo!("$output->getFormatter()"), + &bar.get_progress_character(), + )) as f64; + display.push_str(&format!( + "{}{}", + bar.get_progress_character(), + shirabe_php_shim::str_repeat( + &bar.get_empty_bar_character(), + empty_bars as usize + ) + )); + let _ = output; + } + + Ok(Ok(shirabe_php_shim::PhpMixed::String(display))) + }, + ), + ); + + formatters.insert( + "elapsed".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_time( + (shirabe_php_shim::time() - bar.get_start_time()) as f64, + ) + .unwrap_or_default(), + ))) + }, + ), + ); + + formatters.insert( + "remaining".to_string(), + Box::new(|bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + if bar.get_max_steps() == 0 { + return Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: "Unable to display the remaining time if the maximum number of steps is not set.".to_string(), + code: 0, + }))); + } + + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_time(bar.get_remaining()).unwrap_or_default(), + ))) + }), + ); + + formatters.insert( + "estimated".to_string(), + Box::new(|bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + if bar.get_max_steps() == 0 { + return Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: "Unable to display the estimated time if the maximum number of steps is not set.".to_string(), + code: 0, + }))); + } + + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_time(bar.get_estimated()).unwrap_or_default(), + ))) + }), + ); + + formatters.insert( + "memory".to_string(), + Box::new( + |_bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::String( + Helper::format_memory(shirabe_php_shim::memory_get_usage()), + ))) + }, + ), + ); + + formatters.insert( + "current".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::String( + shirabe_php_shim::str_pad( + &bar.get_progress().to_string(), + bar.get_step_width() as usize, + " ", + shirabe_php_shim::STR_PAD_LEFT, + ), + ))) + }, + ), + ); + + formatters.insert( + "max".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::Int(bar.get_max_steps()))) + }, + ), + ); + + formatters.insert( + "percent".to_string(), + Box::new( + |bar: &ProgressBar, _output: &Rc<RefCell<dyn OutputInterface>>| { + Ok(Ok(shirabe_php_shim::PhpMixed::Float( + shirabe_php_shim::floor(bar.get_progress_percent() * 100.0), + ))) + }, + ), + ); + + formatters + } + + fn init_formats() -> IndexMap<String, String> { + let mut formats: IndexMap<String, String> = IndexMap::new(); + + formats.insert( + FORMAT_NORMAL.to_string(), + " %current%/%max% [%bar%] %percent:3s%%".to_string(), + ); + formats.insert( + FORMAT_NORMAL_NOMAX.to_string(), + " %current% [%bar%]".to_string(), + ); + + formats.insert( + FORMAT_VERBOSE.to_string(), + " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%".to_string(), + ); + formats.insert( + FORMAT_VERBOSE_NOMAX.to_string(), + " %current% [%bar%] %elapsed:6s%".to_string(), + ); + + formats.insert( + FORMAT_VERY_VERBOSE.to_string(), + " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%".to_string(), + ); + formats.insert( + FORMAT_VERY_VERBOSE_NOMAX.to_string(), + " %current% [%bar%] %elapsed:6s%".to_string(), + ); + + formats.insert( + FORMAT_DEBUG.to_string(), + " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%" + .to_string(), + ); + formats.insert( + FORMAT_DEBUG_NOMAX.to_string(), + " %current% [%bar%] %elapsed:6s% %memory:6s%".to_string(), + ); + + formats + } + + fn build_line(&mut self) -> anyhow::Result<String> { + let regex = "{%([a-z\\-_]+)(?:\\:([^%]+))?%}i"; + + // The callback resolves a placeholder match into its replacement text. + // It is invoked by preg_replace_callback over $this->format. + let line = self.build_line_apply(regex)?; + + // gets string length for each sub line with multiline format + let lines_length: Vec<i64> = shirabe_php_shim::explode("\n", &line) + .iter() + .map(|sub_line| { + Helper::width(&Helper::remove_decoration( + todo!("$this->output->getFormatter()"), + &shirabe_php_shim::rtrim(sub_line, Some("\r")), + )) + }) + .collect(); + + let lines_width = *lines_length.iter().max().unwrap(); + + let terminal_width = self.terminal.get_width(); + if lines_width <= terminal_width { + return Ok(line); + } + + self.set_bar_width(self.bar_width - lines_width + terminal_width); + + self.build_line_apply(regex) + } + + /// Applies the placeholder-resolving callback over `$this->format`, mirroring + /// the `preg_replace_callback` invocation in PHP's `buildLine()`. + fn build_line_apply(&self, regex: &str) -> anyhow::Result<String> { + let format = self.format.clone().unwrap_or_default(); + + // $callback in PHP, expressed as a closure over $this and the matches. + let callback = |matches: &[Option<String>]| -> anyhow::Result<String> { + let name = matches[1].clone().unwrap_or_default(); + + let text: shirabe_php_shim::PhpMixed = + if Self::get_placeholder_formatter_definition(&name).is_some() { + // $text = $formatter($this, $this->output); + let formatter_result = FORMATTERS.with(|formatters| { + let formatters = formatters.borrow(); + let formatter = formatters.as_ref().unwrap().get(&name).unwrap(); + formatter(self, &self.output) + }); + match formatter_result? { + Ok(text) => text, + Err(e) => return Err(anyhow::Error::new(e)), + } + } else if let Some(message) = self.messages.get(&name) { + shirabe_php_shim::PhpMixed::String(message.clone()) + } else { + return Ok(matches[0].clone().unwrap_or_default()); + }; + + if let Some(modifier) = matches.get(2).and_then(|m| m.clone()) { + return Ok(shirabe_php_shim::sprintf(&format!("%{modifier}"), &[text])); + } + + // PHP implicitly casts the formatter result to string here. + Ok(match text { + shirabe_php_shim::PhpMixed::String(s) => s, + shirabe_php_shim::PhpMixed::Int(i) => i.to_string(), + shirabe_php_shim::PhpMixed::Float(f) => { + shirabe_php_shim::sprintf("%s", &[shirabe_php_shim::PhpMixed::Float(f)]) + } + other => shirabe_php_shim::sprintf("%s", &[other]), + }) + }; + + shirabe_php_shim::preg_replace_callback(regex, callback, &format) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs index c17cb6b..7ddb620 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs @@ -1,25 +1,933 @@ -use crate::symfony::console::helper::HelperInterface; -use crate::symfony::console::input::InputInterface; -use crate::symfony::console::output::OutputInterface; -use crate::symfony::console::question::Question; +use crate::symfony::console::cursor::Cursor; +use crate::symfony::console::exception::missing_input_exception::MissingInputException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::formatter::output_formatter_style::OutputFormatterStyle; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::helper_interface::HelperInterface; +use crate::symfony::console::helper::helper_set::HelperSet; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::output::console_output::ConsoleOutput; +use crate::symfony::console::output::console_output_interface::ConsoleOutputInterface; +use crate::symfony::console::output::console_section_output::ConsoleSectionOutput; +use crate::symfony::console::output::output_interface; +use crate::symfony::console::output::output_interface::OutputInterface; +use crate::symfony::console::question::choice_question::ChoiceQuestion; +use crate::symfony::console::question::question::Question; +use crate::symfony::console::terminal::Terminal; +use crate::symfony::string::s; +use shirabe_php_shim::AsAny; use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; -#[derive(Debug)] -pub struct QuestionHelper; +/// The QuestionHelper class provides helpers to interact with the user. +#[derive(Debug, Default)] +pub struct QuestionHelper { + pub(crate) inner: Helper, + + /// @var resource|null + input_stream: Option<shirabe_php_shim::PhpResource>, +} + +/// self::$stty +static STTY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); +/// self::$stdinIsInteractive +static STDIN_IS_INTERACTIVE: std::sync::Mutex<Option<bool>> = std::sync::Mutex::new(None); impl QuestionHelper { + /// Asks a question to the user. + /// + /// @return mixed The user answer + /// + /// @throws RuntimeException If there is no data to read in the input stream pub fn ask( + &mut self, + input: &mut dyn InputInterface, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) -> anyhow::Result<Result<PhpMixed, MissingInputException>> { + let mut output = output; + let error_output = { + let borrowed = output.borrow(); + (*borrowed) + .as_any() + .downcast_ref::<ConsoleOutput>() + .map(|console_output| console_output.get_error_output()) + }; + if let Some(error_output) = error_output { + output = error_output; + } + + if !input.is_interactive() { + return Ok(Ok(self.get_default_answer(question))); + } + + // TODO(phase-b): `$input instanceof StreamableInputInterface` cannot be + // expressed as a trait-object-to-trait-object downcast, and no concrete + // streamable input type is wired up yet. The stream is left unset so the + // helper falls back to STDIN. Revisit once StreamableInputInterface has a + // concrete implementor and the PhpResource/PhpMixed split is resolved. + let _ = &mut self.input_stream; + + let result: anyhow::Result<Result<PhpMixed, MissingInputException>> = (|| { + if question.get_validator().is_none() { + return self.do_ask(Rc::clone(&output), question); + } + + let interviewer = || self.do_ask(Rc::clone(&output), question); + + self.validate_attempts(&interviewer, Rc::clone(&output), question) + })(); + + let result = result?; + match result { + Ok(value) => Ok(Ok(value)), + Err(exception) => { + input.set_interactive(false); + + let fallback_output = self.get_default_answer(question); + if matches!(fallback_output, PhpMixed::Null) { + return Ok(Err(exception)); + } + + Ok(Ok(fallback_output)) + } + } + } + + pub fn get_name(&self) -> String { + "question".to_string() + } + + /// Prevents usage of stty. + pub fn disable_stty() { + STTY.store(false, std::sync::atomic::Ordering::SeqCst); + } + + /// Asks the question to the user. + /// + /// @return mixed + /// + /// @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden + fn do_ask( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) -> anyhow::Result<Result<PhpMixed, MissingInputException>> { + self.write_prompt(Rc::clone(&output), question); + + let input_stream = self + .input_stream + .clone() + .unwrap_or_else(shirabe_php_shim::stdin); + let autocomplete = question.get_autocompleter_callback(); + + let ret: PhpMixed; + if autocomplete.is_none() + || !STTY.load(std::sync::atomic::Ordering::SeqCst) + || !Terminal::has_stty_available() + { + let mut r: PhpMixed = PhpMixed::Bool(false); + if question.is_hidden() { + match self.get_hidden_response( + Rc::clone(&output), + &input_stream, + question.is_trimmable(), + )? { + Ok(hidden_response) => { + r = PhpMixed::String(if question.is_trimmable() { + shirabe_php_shim::trim(&hidden_response, None) + } else { + hidden_response + }); + } + Err(e) => { + if !question.is_hidden_fallback() { + return Err(e.into()); + } + } + } + } + + if matches!(r, PhpMixed::Bool(false)) { + let is_blocked = shirabe_php_shim::stream_get_meta_data(&input_stream) + .get("blocked") + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Bool(true)); + + if !shirabe_php_shim::boolval(&is_blocked) { + shirabe_php_shim::stream_set_blocking(&input_stream, true); + } + + let read = self.read_input(&input_stream, question); + + if !shirabe_php_shim::boolval(&is_blocked) { + shirabe_php_shim::stream_set_blocking(&input_stream, false); + } + + if matches!(read, PhpMixed::Bool(false)) { + return Ok(Err(MissingInputException(RuntimeException( + shirabe_php_shim::RuntimeException { + message: "Aborted.".to_string(), + code: 0, + }, + )))); + } + r = read; + if question.is_trimmable() { + r = PhpMixed::String(shirabe_php_shim::trim(&r.to_string(), None)); + } + } + ret = r; + } else { + let callback = autocomplete.unwrap(); + // The autocompleter callback yields an iterable (Option here); PHP + // treats a null result as an empty list of suggestions. + let callback = move |input: &str| callback(input).unwrap_or_default(); + let autocomplete = + self.autocomplete(Rc::clone(&output), question, &input_stream, &callback); + ret = PhpMixed::String(if question.is_trimmable() { + shirabe_php_shim::trim(&autocomplete, None) + } else { + autocomplete + }); + } + + let mut ret = ret; + { + let borrowed = output.borrow(); + if let Some(section_output) = + (*borrowed).as_any().downcast_ref::<ConsoleSectionOutput>() + { + section_output.add_content(&ret.to_string()); + } + } + + ret = if shirabe_php_shim::strlen(&ret.to_string()) > 0 { + ret + } else { + question.get_default() + }; + + if let Some(normalizer) = question.get_normalizer() { + return Ok(Ok(normalizer(ret))); + } + + Ok(Ok(ret)) + } + + /// @return mixed + fn get_default_answer(&self, question: &Question) -> PhpMixed { + let default = question.get_default(); + + if matches!(default, PhpMixed::Null) { + return default; + } + + if let Some(validator) = question.get_validator() { + // call_user_func($question->getValidator(), $default) + return validator(Some(default)).unwrap(); + } else if let Some(choice_question) = question_as_choice_question(question) { + let choices = choice_question.get_choices(); + + if !choice_question.is_multiselect() { + return choices + .get(&default.to_string()) + .map(|v| (**v).clone()) + .unwrap_or(default); + } + + let default_parts = shirabe_php_shim::explode(",", &default.to_string()); + let mut resolved: indexmap::IndexMap<String, Box<PhpMixed>> = indexmap::IndexMap::new(); + for (k, v) in default_parts.iter().enumerate() { + let v = if question.is_trimmable() { + shirabe_php_shim::trim(v, None) + } else { + v.clone() + }; + let value = choices + .get(&v) + .map(|value| (**value).clone()) + .unwrap_or(PhpMixed::String(v)); + resolved.insert(k.to_string(), Box::new(value)); + } + + return PhpMixed::Array(resolved); + } + + default + } + + /// Outputs the question prompt. + pub(crate) fn write_prompt( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) { + let mut message = question.get_question().to_string(); + + if let Some(choice_question) = question_as_choice_question(question) { + let mut lines = vec![question.get_question().to_string()]; + lines.extend(self.format_choice_question_choices(choice_question, "info")); + output + .borrow() + .writeln(&lines, output_interface::OUTPUT_NORMAL); + + message = choice_question.get_prompt().to_string(); + } + + output + .borrow() + .write(&[message], false, output_interface::OUTPUT_NORMAL); + } + + /// @return string[] + pub(crate) fn format_choice_question_choices( + &self, + question: &ChoiceQuestion, + tag: &str, + ) -> Vec<String> { + let mut messages: Vec<String> = vec![]; + + let choices = question.get_choices(); + let max_width = choices + .keys() + .map(|key| Helper::width(key)) + .max() + .unwrap_or(0); + + for (key, value) in choices { + let padding = + shirabe_php_shim::str_repeat(" ", (max_width - Helper::width(key)) as usize); + + messages.push(shirabe_php_shim::sprintf( + &format!(" [<{tag}>%s{padding}</{tag}>] %s"), + &[PhpMixed::String(key.clone()), (**value).clone()], + )); + } + + messages + } + + /// Outputs an error message. + pub(crate) fn write_error( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + error: &shirabe_php_shim::Exception, + ) { + let message; + if let Some(helper_set) = self.get_helper_set() { + if helper_set.borrow().has("formatter") { + // PHP: `$this->getHelperSet()->get('formatter')->formatBlock(...)`. + // HelperSet::get yields `dyn HelperInterface`, which does not have + // `AsAny` as a supertrait, so it cannot be downcast to + // FormatterHelper. Resolved once HelperInterface gains AsAny (see + // report). + let _formatter = helper_set.borrow().get("formatter").unwrap(); + todo!("downcast dyn HelperInterface to FormatterHelper for format_block"); + } else { + message = format!("<error>{}</error>", error.message); + } + } else { + message = format!("<error>{}</error>", error.message); + } + + output + .borrow() + .writeln(&[message], output_interface::OUTPUT_NORMAL); + } + + /// Autocompletes a question. + /// + /// @param resource $inputStream + fn autocomplete( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + input_stream: &shirabe_php_shim::PhpResource, + autocomplete: &dyn Fn(&str) -> Vec<PhpMixed>, + ) -> String { + // TODO(phase-b): Cursor takes Option<PhpMixed>, but the input stream is a + // PhpResource and PhpMixed has no resource variant. Defaulting to STDIN + // until the PhpResource/PhpMixed stream representation is unified. + let cursor = Cursor::new(Rc::clone(&output), None); + + let mut full_choice = String::new(); + let mut ret = String::new(); + + let mut i: i64 = 0; + let mut ofs: i64 = -1; + let mut matches = autocomplete(&ret); + let mut num_matches = matches.len() as i64; + + let stty_mode = shirabe_php_shim::shell_exec("stty -g").unwrap_or_default(); + let is_stdin = shirabe_php_shim::stream_get_meta_data(input_stream) + .get("uri") + .map(|uri| uri.to_string() == "php://stdin") + .unwrap_or(false); + let mut r = vec![input_stream.clone()]; + let w: Vec<shirabe_php_shim::PhpResource> = vec![]; + + // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead) + shirabe_php_shim::shell_exec("stty -icanon -echo"); + + // Add highlighted text style + output.borrow().get_formatter().borrow_mut().set_style( + "hl", + Box::new(OutputFormatterStyle::new( + Some("black"), + Some("white"), + vec![], + )), + ); + + // Read a keypress + while !feof_resource(input_stream) { + while is_stdin + && 0 == shirabe_php_shim::stream_select( + &mut r, + &mut w.clone(), + &mut w.clone(), + 0, + Some(100), + ) + { + // Give signal handlers a chance to run + r = vec![input_stream.clone()]; + } + let mut c = fread_resource(input_stream, 1); + + // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false. + if c.is_none() + || (ret.is_empty() + && c.as_deref() == Some("") + && matches!(question.get_default(), PhpMixed::Null)) + { + shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode)); + // throw new MissingInputException('Aborted.'); + // autocomplete() returns string in PHP; this throw aborts the + // whole read. Faithful exception propagation is resolved later. + todo!("MissingInputException('Aborted.') thrown inside autocomplete"); + } else if c.as_deref() == Some("\u{7f}") { + // Backspace Character + if 0 == num_matches && 0 != i { + i -= 1; + cursor.move_left(s(&full_choice).slice(-1, None).width(false)); + + full_choice = QuestionHelper::substr(Some(&full_choice), 0, Some(i)); + } + + if 0 == i { + ofs = -1; + matches = autocomplete(&ret); + num_matches = matches.len() as i64; + } else { + num_matches = 0; + } + + // Pop the last character off the end of our string + ret = QuestionHelper::substr(Some(&ret), 0, Some(i)); + } else if c.as_deref() == Some("\u{1b}") { + // Did we read an escape sequence? + let escape = fread_resource(input_stream, 2).unwrap_or_default(); + let cc = format!("{}{}", c.clone().unwrap_or_default(), escape); + c = Some(cc.clone()); + + // A = Up Arrow. B = Down Arrow + let c2 = cc.as_bytes().get(2).copied(); + if c2 == Some(b'A') || c2 == Some(b'B') { + if c2 == Some(b'A') && -1 == ofs { + ofs = 0; + } + + if 0 == num_matches { + continue; + } + + ofs += if c2 == Some(b'A') { -1 } else { 1 }; + ofs = (num_matches + ofs) % num_matches; + } + } else if shirabe_php_shim::ord(c.as_deref().unwrap_or("")) < 32 { + if c.as_deref() == Some("\t") || c.as_deref() == Some("\n") { + if num_matches > 0 && -1 != ofs { + ret = matches[ofs as usize].to_string(); + // Echo out remaining chars for current match + let remaining_characters = shirabe_php_shim::substr( + &ret, + shirabe_php_shim::strlen(&shirabe_php_shim::trim( + &self.most_recently_entered_value(&full_choice), + None, + )), + None, + ); + output.borrow().write( + &[remaining_characters.clone()], + false, + output_interface::OUTPUT_NORMAL, + ); + full_choice.push_str(&remaining_characters); + i = match shirabe_php_shim::mb_detect_encoding(&full_choice, None, true) { + None => shirabe_php_shim::strlen(&full_choice), + Some(encoding) => shirabe_php_shim::mb_strlen(&full_choice, &encoding), + }; + + let ret_for_filter = ret.clone(); + matches = autocomplete(&ret) + .into_iter() + .filter(|m| { + ret_for_filter.is_empty() + || shirabe_php_shim::str_starts_with( + &m.to_string(), + &ret_for_filter, + ) + }) + .collect(); + num_matches = matches.len() as i64; + ofs = -1; + } + + if c.as_deref() == Some("\n") { + output.borrow().write( + &[c.clone().unwrap_or_default()], + false, + output_interface::OUTPUT_NORMAL, + ); + break; + } + + num_matches = 0; + } + + continue; + } else { + let cur = c.clone().unwrap_or_default(); + if "\u{80}" <= cur.as_str() { + let len = match shirabe_php_shim::str_bitand(&cur, "\u{f0}").as_str() { + "\u{c0}" => 1, + "\u{d0}" => 1, + "\u{e0}" => 2, + "\u{f0}" => 3, + _ => 0, + }; + let extra = fread_resource(input_stream, len).unwrap_or_default(); + c = Some(format!("{}{}", cur, extra)); + } + + let cur = c.clone().unwrap_or_default(); + output + .borrow() + .write(&[cur.clone()], false, output_interface::OUTPUT_NORMAL); + ret.push_str(&cur); + full_choice.push_str(&cur); + i += 1; + + let mut temp_ret = ret.clone(); + + if let Some(choice_question) = question_as_choice_question(question) { + if choice_question.is_multiselect() { + temp_ret = self.most_recently_entered_value(&full_choice); + } + } + + num_matches = 0; + ofs = 0; + + for value in autocomplete(&ret) { + // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle) + if shirabe_php_shim::str_starts_with(&value.to_string(), &temp_ret) { + if (num_matches as usize) < matches.len() { + matches[num_matches as usize] = value; + } else { + matches.push(value); + } + num_matches += 1; + } + } + } + + cursor.clear_line_after(); + + if num_matches > 0 && -1 != ofs { + cursor.save_position(); + // Write highlighted text, complete the partially entered response + let characters_entered = shirabe_php_shim::strlen(&shirabe_php_shim::trim( + &self.most_recently_entered_value(&full_choice), + None, + )); + output.borrow().write( + &[format!( + "<hl>{}</hl>", + OutputFormatter::escape_trailing_backslash(&shirabe_php_shim::substr( + &matches[ofs as usize].to_string(), + characters_entered, + None, + )) + )], + false, + output_interface::OUTPUT_NORMAL, + ); + cursor.restore_position(); + } + } + + // Reset stty so it behaves normally again + shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode)); + + full_choice + } + + fn most_recently_entered_value(&self, entered: &str) -> String { + // Determine the most recent value that the user entered + if !shirabe_php_shim::str_contains(entered, ",") { + return entered.to_string(); + } + + let choices = shirabe_php_shim::explode(",", entered); + let last_choice = shirabe_php_shim::trim(&choices[choices.len() - 1], None); + if !last_choice.is_empty() { + return last_choice; + } + + entered.to_string() + } + + /// Gets a hidden response from user. + /// + /// @param resource $inputStream The handler resource + /// @param bool $trimmable Is the answer trimmable + /// + /// @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden + fn get_hidden_response( &self, - _input: std::rc::Rc<std::cell::RefCell<dyn InputInterface>>, - _output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, - _question: &Question, + output: Rc<RefCell<dyn OutputInterface>>, + input_stream: &shirabe_php_shim::PhpResource, + trimmable: bool, + ) -> anyhow::Result<Result<String, RuntimeException>> { + if shirabe_php_shim::DIRECTORY_SEPARATOR == "\\" { + let mut exe = format!( + "{}/../Resources/bin/hiddeninput.exe", + shirabe_php_shim::dir() + ); + + // handle code running from a phar + let mut tmp_exe: Option<String> = None; + if shirabe_php_shim::substr(&magic_file(), 0, Some(5)) == "phar:" { + let tmp = format!("{}/hiddeninput.exe", shirabe_php_shim::sys_get_temp_dir()); + shirabe_php_shim::copy(&exe, &tmp); + exe = tmp.clone(); + tmp_exe = Some(tmp); + } + + let s_exec = shirabe_php_shim::shell_exec(&format!("\"{}\"", exe)).unwrap_or_default(); + let value = if trimmable { + shirabe_php_shim::rtrim(&s_exec, None) + } else { + s_exec + }; + output + .borrow() + .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); + + if let Some(tmp) = tmp_exe { + shirabe_php_shim::unlink(&tmp); + } + + return Ok(Ok(value)); + } + + let mut stty_mode = String::new(); + if STTY.load(std::sync::atomic::Ordering::SeqCst) && Terminal::has_stty_available() { + stty_mode = shirabe_php_shim::shell_exec("stty -g").unwrap_or_default(); + shirabe_php_shim::shell_exec("stty -echo"); + } else if self.is_interactive_input(input_stream) { + return Ok(Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: "Unable to hide the response.".to_string(), + code: 0, + }))); + } + + let value = fgets_resource(input_stream, Some(4096)); + + if STTY.load(std::sync::atomic::Ordering::SeqCst) && Terminal::has_stty_available() { + shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode)); + } + + let mut value = match value { + Some(value) => value, + None => { + return Err(MissingInputException(RuntimeException( + shirabe_php_shim::RuntimeException { + message: "Aborted.".to_string(), + code: 0, + }, + )) + .into()); + } + }; + if trimmable { + value = shirabe_php_shim::trim(&value, None); + } + output + .borrow() + .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL); + + Ok(Ok(value)) + } + + /// Validates an attempt. + /// + /// @param callable $interviewer A callable that will ask for a question and return the result + /// + /// @return mixed The validated response + /// + /// @throws \Exception In case the max number of attempts has been reached and no valid response has been given + fn validate_attempts( + &self, + interviewer: &dyn Fn() -> anyhow::Result<Result<PhpMixed, MissingInputException>>, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) -> anyhow::Result<Result<PhpMixed, MissingInputException>> { + let mut error: Option<shirabe_php_shim::Exception> = None; + let mut attempts = question.get_max_attempts(); + + loop { + // while (null === $attempts || $attempts--) + match attempts { + None => {} + Some(0) => break, + Some(n) => attempts = Some(n - 1), + } + + if let Some(ref error) = error { + self.write_error(Rc::clone(&output), error); + } + + let interviewed = match interviewer()? { + Ok(value) => value, + Err(missing) => return Ok(Err(missing)), + }; + + match question.get_validator().unwrap()(Some(interviewed)) { + Ok(value) => return Ok(Ok(value)), + Err(e) => { + // PHP: `catch (RuntimeException $e) { throw $e; } catch (\Exception $error) {}`. + // The validator return type is fixed to InvalidArgumentException here, so the + // RuntimeException rethrow branch is statically unreachable; record the error + // and retry. + error = Some(shirabe_php_shim::Exception { + message: e.0.message.clone(), + code: e.0.code, + }); + } + } + } + + // throw $error; + Err(anyhow::Error::msg( + error.map(|e| e.message).unwrap_or_default(), + )) + } + + fn is_interactive_input(&self, input_stream: &shirabe_php_shim::PhpResource) -> bool { + let uri = shirabe_php_shim::stream_get_meta_data(input_stream) + .get("uri") + .map(|uri| uri.to_string()); + if uri.as_deref() != Some("php://stdin") { + return false; + } + + let mut stdin_is_interactive = STDIN_IS_INTERACTIVE.lock().unwrap(); + if let Some(value) = *stdin_is_interactive { + return value; + } + + let value = shirabe_php_shim::stream_isatty_resource( + &shirabe_php_shim::php_fopen_resource("php://stdin", "r"), + ); + *stdin_is_interactive = Some(value); + value + } + + /// Reads one or more lines of input and returns what is read. + /// + /// @param resource $inputStream The handler resource + /// @param Question $question The question being asked + /// + /// @return string|false The input received, false in case input could not be read + fn read_input( + &self, + input_stream: &shirabe_php_shim::PhpResource, + question: &Question, ) -> PhpMixed { - todo!() + if !question.is_multiline() { + let cp = self.set_io_codepage(); + let ret = fgets_resource(input_stream, Some(4096)); + + return self.reset_io_codepage( + cp, + ret.map(PhpMixed::String).unwrap_or(PhpMixed::Bool(false)), + ); + } + + let multi_line_stream_reader = self.clone_input_stream(input_stream); + let multi_line_stream_reader = match multi_line_stream_reader { + Some(reader) => reader, + None => return PhpMixed::Bool(false), + }; + + let mut ret = String::new(); + let cp = self.set_io_codepage(); + loop { + let char = shirabe_php_shim::fgetc(&multi_line_stream_reader); + let char = match char { + Some(char) => char, + None => break, + }; + if shirabe_php_shim::PHP_EOL == format!("{}{}", ret, char) { + break; + } + ret.push_str(&char); + } + + self.reset_io_codepage(cp, PhpMixed::String(ret)) + } + + /// Sets console I/O to the host code page. + /// + /// @return int Previous code page in IBM/EBCDIC format + fn set_io_codepage(&self) -> i64 { + if shirabe_php_shim::function_exists("sapi_windows_cp_set") { + let cp = shirabe_php_shim::sapi_windows_cp_get(None); + shirabe_php_shim::sapi_windows_cp_set(shirabe_php_shim::sapi_windows_cp_get(Some( + "oem", + ))); + + return cp; + } + + 0 } + + /// Sets console I/O to the specified code page and converts the user input. + /// + /// @param string|false $input + /// + /// @return string|false + fn reset_io_codepage(&self, cp: i64, input: PhpMixed) -> PhpMixed { + let mut input = input; + if 0 != cp { + shirabe_php_shim::sapi_windows_cp_set(cp); + + if !matches!(input, PhpMixed::Bool(false)) && input.to_string() != "" { + input = PhpMixed::String(shirabe_php_shim::sapi_windows_cp_conv( + shirabe_php_shim::sapi_windows_cp_get(Some("oem")), + cp, + &input.to_string(), + )); + } + } + + input + } + + /// Clones an input stream in order to act on one instance of the same + /// stream without affecting the other instance. + /// + /// @param resource $inputStream The handler resource + /// + /// @return resource|null The cloned resource, null in case it could not be cloned + fn clone_input_stream( + &self, + input_stream: &shirabe_php_shim::PhpResource, + ) -> Option<shirabe_php_shim::PhpResource> { + let stream_meta_data = shirabe_php_shim::stream_get_meta_data(input_stream); + let seekable = stream_meta_data + .get("seekable") + .map(|v| (**v).clone()) + .unwrap_or(PhpMixed::Bool(false)); + let mode = stream_meta_data + .get("mode") + .map(|m| m.to_string()) + .unwrap_or_else(|| "rb".to_string()); + let uri = stream_meta_data.get("uri").map(|u| u.to_string()); + + let uri = uri?; + + let clone_stream = shirabe_php_shim::php_fopen_resource(&uri, &mode); + + // For seekable and writable streams, add all the same data to the + // cloned stream and then seek to the same offset. + if matches!(seekable, PhpMixed::Bool(true)) && !["r", "rb", "rt"].contains(&mode.as_str()) { + let offset = shirabe_php_shim::ftell(input_stream); + rewind_resource(input_stream); + stream_copy_to_stream_resource(input_stream, &clone_stream); + fseek_resource(input_stream, offset); + fseek_resource(&clone_stream, offset); + } + + Some(clone_stream) + } + + /// Helper::substr proxy (inherited static helper). + fn substr(string: Option<&str>, from: i64, length: Option<i64>) -> String { + Helper::substr(string.unwrap_or(""), from, length) + } +} + +/// Downcasts a Question to a ChoiceQuestion if applicable. +fn question_as_choice_question(question: &Question) -> Option<&ChoiceQuestion> { + question.as_any().downcast_ref::<ChoiceQuestion>() +} + +// PHP operates on raw stream resources, but the shim models `feof`/`fread`/ +// `fgets`/`fseek`/`rewind`/`stream_copy_to_stream` over `PhpMixed`, which has no +// resource variant. These thin resource-typed wrappers stand in until the shim +// grows `*_resource` counterparts (see report). Each defers to the real +// implementation once it exists. +// PHP `__FILE__` magic constant. The shim's `file()` is PHP's file() function, +// not the magic constant, and there is no `__FILE__` shim yet (see report). +fn magic_file() -> String { + todo!("magic_file: shim needs a __FILE__ magic-constant equivalent") +} + +fn feof_resource(_stream: &shirabe_php_shim::PhpResource) -> bool { + todo!("feof_resource: shim needs a PhpResource-based feof") +} + +fn fread_resource(_stream: &shirabe_php_shim::PhpResource, _length: i64) -> Option<String> { + todo!("fread_resource: shim needs a PhpResource-based fread") +} + +fn fgets_resource(_stream: &shirabe_php_shim::PhpResource, _length: Option<i64>) -> Option<String> { + todo!("fgets_resource: shim needs a PhpResource-based fgets") +} + +fn fseek_resource(_stream: &shirabe_php_shim::PhpResource, _offset: i64) -> i64 { + todo!("fseek_resource: shim needs a PhpResource-based fseek") +} + +fn rewind_resource(_stream: &shirabe_php_shim::PhpResource) -> bool { + todo!("rewind_resource: shim needs a PhpResource-based rewind") +} + +fn stream_copy_to_stream_resource( + _source: &shirabe_php_shim::PhpResource, + _dest: &shirabe_php_shim::PhpResource, +) -> Option<i64> { + todo!("stream_copy_to_stream_resource: shim needs a PhpResource-based stream_copy_to_stream") } impl HelperInterface for QuestionHelper { - fn as_any(&self) -> &dyn std::any::Any { - self + fn set_helper_set(&mut self, helper_set: Option<Rc<RefCell<HelperSet>>>) { + self.inner.set_helper_set(helper_set); + } + + fn get_helper_set(&self) -> Option<Rc<RefCell<HelperSet>>> { + self.inner.get_helper_set() + } + + fn get_name(&self) -> String { + self.get_name() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/symfony_question_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/symfony_question_helper.rs new file mode 100644 index 0000000..dd14513 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/symfony_question_helper.rs @@ -0,0 +1,182 @@ +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::helper::question_helper::QuestionHelper; +use crate::symfony::console::output::output_interface; +use crate::symfony::console::output::output_interface::OutputInterface; +use crate::symfony::console::question::choice_question::ChoiceQuestion; +use crate::symfony::console::question::confirmation_question::ConfirmationQuestion; +use crate::symfony::console::question::question::Question; +use crate::symfony::console::style::symfony_style::SymfonyStyle; +use shirabe_php_shim::AsAny; +use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +/// Symfony Style Guide compliant question helper. +#[derive(Debug, Default)] +pub struct SymfonyQuestionHelper { + inner: QuestionHelper, +} + +impl SymfonyQuestionHelper { + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn write_prompt( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + question: &Question, + ) { + let mut text = OutputFormatter::escape_trailing_backslash(&question.get_question()); + let default = question.get_default(); + + if question.is_multiline() { + text += &shirabe_php_shim::sprintf( + " (press %s to continue)", + &[PhpMixed::String(self.get_eof_shortcut())], + ); + } + + // switch (true) + if matches!(default, PhpMixed::Null) { + text = shirabe_php_shim::sprintf(" <info>%s</info>:", &[PhpMixed::String(text)]); + } else if question + .as_any() + .downcast_ref::<ConfirmationQuestion>() + .is_some() + { + text = shirabe_php_shim::sprintf( + " <info>%s (yes/no)</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String( + if shirabe_php_shim::boolval(&default) { + "yes" + } else { + "no" + } + .to_string(), + ), + ], + ); + } else if let Some(choice_question) = question + .as_any() + .downcast_ref::<ChoiceQuestion>() + .filter(|q| q.is_multiselect()) + { + let choices = choice_question.get_choices(); + let default_parts = shirabe_php_shim::explode(",", &default.to_string()); + + let resolved: Vec<String> = default_parts + .iter() + .map(|value| { + choices + .get(&shirabe_php_shim::trim(value, None)) + .map(|v| v.to_string()) + .unwrap() + }) + .collect(); + + text = shirabe_php_shim::sprintf( + " <info>%s</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String(OutputFormatter::escape(&resolved.join(", ")).unwrap()), + ], + ); + } else if let Some(choice_question) = question.as_any().downcast_ref::<ChoiceQuestion>() { + let choices = choice_question.get_choices(); + text = shirabe_php_shim::sprintf( + " <info>%s</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String( + OutputFormatter::escape( + &choices + .get(&default.to_string()) + .map(|v| (**v).clone()) + .unwrap_or(default.clone()) + .to_string(), + ) + .unwrap(), + ), + ], + ); + } else { + text = shirabe_php_shim::sprintf( + " <info>%s</info> [<comment>%s</comment>]:", + &[ + PhpMixed::String(text), + PhpMixed::String(OutputFormatter::escape(&default.to_string()).unwrap()), + ], + ); + } + + output + .borrow() + .writeln(&[text], output_interface::OUTPUT_NORMAL); + + let mut prompt = " > ".to_string(); + + if let Some(choice_question) = question.as_any().downcast_ref::<ChoiceQuestion>() { + output.borrow().writeln( + &self + .inner + .format_choice_question_choices(choice_question, "comment"), + output_interface::OUTPUT_NORMAL, + ); + + prompt = choice_question.get_prompt().to_string(); + } + + output + .borrow() + .write(&[prompt], false, output_interface::OUTPUT_NORMAL); + } + + pub(crate) fn write_error( + &self, + output: Rc<RefCell<dyn OutputInterface>>, + error: &shirabe_php_shim::Exception, + ) { + let is_symfony_style = { + let borrowed = output.borrow(); + (*borrowed) + .as_any() + .downcast_ref::<SymfonyStyle>() + .is_some() + }; + if is_symfony_style { + // $output->newLine(); $output->error($error->getMessage()); + // SymfonyStyle's newLine()/error() require mutable access to the + // concrete type; mutable downcasting through the trait object is + // resolved in a later phase. + todo!("SymfonyStyle newLine()/error() require &mut SymfonyStyle"); + } + + self.inner.write_error(output, error); + } + + fn get_eof_shortcut(&self) -> String { + if shirabe_php_shim::php_os_family() == "Windows" { + return "<comment>Ctrl+Z</comment> then <comment>Enter</comment>".to_string(); + } + + "<comment>Ctrl+D</comment>".to_string() + } +} + +impl Deref for SymfonyQuestionHelper { + type Target = QuestionHelper; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for SymfonyQuestionHelper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs index 4ff5a5e..6f52458 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs @@ -1,47 +1,1356 @@ -use crate::symfony::console::output::OutputInterface; +use crate::composer::pcre::preg::Preg; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::formatter::output_formatter::OutputFormatter; +use crate::symfony::console::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface; +use crate::symfony::console::helper::helper::Helper; +use crate::symfony::console::helper::table_cell::{TableCell, TableCellOption}; +use crate::symfony::console::helper::table_cell_style::TableCellStyle; +use crate::symfony::console::helper::table_rows::TableRows; +use crate::symfony::console::helper::table_separator::TableSeparator; +use crate::symfony::console::helper::table_style::TableStyle; +use crate::symfony::console::output::console_section_output::ConsoleSectionOutput; +use crate::symfony::console::output::output_interface::OutputInterface; +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +use std::cell::RefCell; +use std::rc::Rc; +/// Provides helpers to display a table. #[derive(Debug)] -pub struct Table; +pub struct Table { + header_title: Option<String>, + footer_title: Option<String>, + + /// Table headers. + headers: Vec<PhpMixed>, + + /// Table rows. + rows: Vec<PhpMixed>, + horizontal: bool, + + /// Column widths cache. + effective_column_widths: IndexMap<i64, i64>, + + /// Number of columns cache. + number_of_columns: Option<i64>, + + output: Rc<RefCell<dyn OutputInterface>>, + + style: TableStyle, + + column_styles: IndexMap<i64, TableStyle>, + + /// User set column widths. + column_widths: IndexMap<i64, i64>, + column_max_widths: IndexMap<i64, i64>, + + rendered: bool, +} + +const SEPARATOR_TOP: i64 = 0; +const SEPARATOR_TOP_BOTTOM: i64 = 1; +const SEPARATOR_MID: i64 = 2; +const SEPARATOR_BOTTOM: i64 = 3; +const BORDER_OUTSIDE: i64 = 0; +const BORDER_INSIDE: i64 = 1; + +/// Global style definitions, lazily initialized. +/// +/// In PHP this is `private static $styles`. Here it is a process-global cache. +fn styles() -> &'static std::sync::Mutex<Option<IndexMap<String, TableStyle>>> { + static STYLES: std::sync::Mutex<Option<IndexMap<String, TableStyle>>> = + std::sync::Mutex::new(None); + &STYLES +} impl Table { - pub fn new(_output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>) -> Self { + pub fn new(output: Rc<RefCell<dyn OutputInterface>>) -> Self { + let mut styles_guard = styles().lock().unwrap(); + if styles_guard.is_none() { + *styles_guard = Some(Self::init_styles()); + } + drop(styles_guard); + + let mut this = Self { + header_title: None, + footer_title: None, + headers: Vec::new(), + rows: Vec::new(), + horizontal: false, + effective_column_widths: IndexMap::new(), + number_of_columns: None, + output, + style: TableStyle::default(), + column_styles: IndexMap::new(), + column_widths: IndexMap::new(), + column_max_widths: IndexMap::new(), + rendered: false, + }; + + this.set_style(PhpMixed::from("default")); + + this + } + + /// Sets a style definition. + pub fn set_style_definition(name: String, style: TableStyle) { + let mut styles_guard = styles().lock().unwrap(); + if styles_guard.is_none() { + *styles_guard = Some(Self::init_styles()); + } + + styles_guard.as_mut().unwrap().insert(name, style); + } + + /// Gets a style definition by name. + pub fn get_style_definition( + name: String, + ) -> anyhow::Result<Result<TableStyle, InvalidArgumentException>> { + let mut styles_guard = styles().lock().unwrap(); + if styles_guard.is_none() { + *styles_guard = Some(Self::init_styles()); + } + + if let Some(_style) = styles_guard.as_ref().unwrap().get(&name) { + // TODO(phase-b): TableStyle is not Clone; sharing semantics need resolving. + todo!() + } + + Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Style \"%s\" is not defined.", + &[PhpMixed::from(name)], + ), + code: 0, + }, + ))) + } + + /// Sets table style. + /// + /// `$name` is the style name or a TableStyle instance. + pub fn set_style( + &mut self, + name: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + match self.resolve_style(name)? { + Ok(style) => { + self.style = style; + Ok(Ok(self)) + } + Err(e) => Ok(Err(e)), + } + } + + /// Gets the current table style. + pub fn get_style(&self) -> &TableStyle { + &self.style + } + + /// Sets table column style. + /// + /// `$name` is the style name or a TableStyle instance. + pub fn set_column_style( + &mut self, + column_index: i64, + name: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + match self.resolve_style(name)? { + Ok(style) => { + self.column_styles.insert(column_index, style); + Ok(Ok(self)) + } + Err(e) => Ok(Err(e)), + } + } + + /// Gets the current style for a column. + /// + /// If style was not set, it returns the global table style. + pub fn get_column_style(&self, column_index: i64) -> &TableStyle { + self.column_styles + .get(&column_index) + .unwrap_or_else(|| self.get_style()) + } + + /// Sets the minimum width of a column. + pub fn set_column_width(&mut self, column_index: i64, width: i64) -> &mut Self { + self.column_widths.insert(column_index, width); + + self + } + + /// Sets the minimum width of all columns. + pub fn set_column_widths(&mut self, widths: Vec<i64>) -> &mut Self { + self.column_widths = IndexMap::new(); + for (index, width) in widths.into_iter().enumerate() { + self.set_column_width(index as i64, width); + } + + self + } + + /// Sets the maximum width of a column. + /// + /// Any cell within this column which contents exceeds the specified width will be wrapped into + /// multiple lines, while formatted strings are preserved. + pub fn set_column_max_width(&mut self, column_index: i64, width: i64) -> &mut Self { + if !Self::formatter_is_wrappable(&self.output) { + // PHP throws \LogicException here. This represents a programming error: the caller must + // supply a WrappableOutputFormatterInterface before setting a maximum column width. + panic!( + "Setting a maximum column width is only supported when using a \"{}\" formatter, got \"{}\".", + "Symfony\\Component\\Console\\Formatter\\WrappableOutputFormatterInterface", + shirabe_php_shim::get_debug_type(&PhpMixed::from(())) + ); + } + + self.column_max_widths.insert(column_index, width); + + self + } + + pub fn set_headers(&mut self, headers: Vec<PhpMixed>) -> &mut Self { + // PHP: $headers = array_values($headers). A row is already modeled as a positional Vec, + // so reindexing is the identity here. + let mut headers = headers; + if !headers.is_empty() && !shirabe_php_shim::is_array(&headers[0]) { + headers = vec![Self::from_row_vec(headers)]; + } + + self.headers = headers; + + self + } + + pub fn set_rows(&mut self, rows: Vec<PhpMixed>) -> &mut Self { + self.rows = Vec::new(); + + self.add_rows(rows) + } + + pub fn add_rows(&mut self, rows: Vec<PhpMixed>) -> &mut Self { + for row in rows { + self.add_row(row); + } + + self + } + + pub fn add_row( + &mut self, + row: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + if shirabe_php_shim::instance_of::<TableSeparator>(&row) { + self.rows.push(row); + + return Ok(Ok(self)); + } + + if !shirabe_php_shim::is_array(&row) { + return Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "A row must be an array or a TableSeparator instance.".to_string(), + code: 0, + }, + ))); + } + + // PHP: $this->rows[] = array_values($row). The row is modeled as a positional Vec. + self.rows.push(Self::from_row_vec(Self::to_row_vec(row))); + + Ok(Ok(self)) + } + + /// Adds a row to the table, and re-renders the table. + pub fn append_row( + &mut self, + row: PhpMixed, + ) -> anyhow::Result<Result<&mut Self, RuntimeException>> { + if !Self::output_is_console_section(&self.output) { + return Ok(Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "Output should be an instance of \"%s\" when calling \"%s\".", + &[ + PhpMixed::from("Symfony\\Component\\Console\\Output\\ConsoleSectionOutput"), + PhpMixed::from("Symfony\\Component\\Console\\Helper\\Table::appendRow"), + ], + ), + code: 0, + }))); + } + + if self.rendered { + // TODO(phase-b): downcast output to ConsoleSectionOutput to call clear(). + let _ = ConsoleSectionOutput::clear; + let row_count = self.calculate_row_count(); + let _ = row_count; + todo!() + } + + self.add_row(row)?.ok(); + self.render(); + + Ok(Ok(self)) + } + + pub fn set_row(&mut self, column: i64, row: Vec<PhpMixed>) -> &mut Self { + // PHP indexes $this->rows by arbitrary key; here we follow the integer-keyed case. + let _ = (column, row); todo!() } - pub fn set_headers(&mut self, _headers: Vec<PhpMixed>) -> &mut Self { + pub fn set_header_title(&mut self, title: Option<String>) -> &mut Self { + self.header_title = title; + + self + } + + pub fn set_footer_title(&mut self, title: Option<String>) -> &mut Self { + self.footer_title = title; + + self + } + + pub fn set_horizontal(&mut self, horizontal: bool) -> &mut Self { + self.horizontal = horizontal; + + self + } + + /// Renders table to output. + pub fn render(&mut self) { + let divider = TableSeparator::new(); + let rows: Vec<PhpMixed>; + if self.horizontal { + let mut horizontal_rows: IndexMap<i64, Vec<PhpMixed>> = IndexMap::new(); + let header0 = self + .headers + .first() + .map(|h| Self::to_row_vec(h.clone())) + .unwrap_or_default(); + for (i, header) in header0.into_iter().enumerate() { + let i = i as i64; + horizontal_rows.insert(i, vec![header]); + for row in &self.rows { + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + continue; + } + if let Some(cell) = Self::row_get(row, i) { + let entry = horizontal_rows.get_mut(&i).unwrap(); + entry.push(cell); + } else { + let first = horizontal_rows.get(&i).unwrap().first().cloned(); + let is_title_noop = match first { + Some(ref c) if shirabe_php_shim::instance_of::<TableCell>(c) => { + Self::cell_colspan(c) >= 2 + } + _ => false, + }; + if is_title_noop { + // Noop, there is a "title" + } else { + let entry = horizontal_rows.get_mut(&i).unwrap(); + entry.push(PhpMixed::from(())); + } + } + } + } + rows = horizontal_rows + .into_values() + .map(Self::from_row_vec) + .collect(); + } else { + let mut merged = self.headers.clone(); + merged.push(Self::table_separator_to_mixed(divider.clone())); + merged.extend(self.rows.clone()); + rows = merged; + } + + self.calculate_number_of_columns(&rows); + + let row_groups = self.build_table_rows(rows); + self.calculate_columns_width(&row_groups); + + let mut is_header = !self.horizontal; + let mut is_first_row = self.horizontal; + let mut has_title = + self.header_title.is_some() && !self.header_title.as_deref().unwrap_or("").is_empty(); + + for row_group in &row_groups { + let mut is_header_separator_rendered = false; + + for row in row_group { + if Self::is_divider(row, ÷r) { + is_header = false; + is_first_row = true; + + continue; + } + + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + self.render_row_separator(SEPARATOR_MID, None, None); + + continue; + } + + if !shirabe_php_shim::to_bool(row) { + continue; + } + + if is_header && !is_header_separator_rendered { + self.render_row_separator( + if is_header { + SEPARATOR_TOP + } else { + SEPARATOR_TOP_BOTTOM + }, + if has_title { + self.header_title.clone() + } else { + None + }, + if has_title { + Some(self.style.get_header_title_format()) + } else { + None + }, + ); + has_title = false; + is_header_separator_rendered = true; + } + + if is_first_row { + self.render_row_separator( + if is_header { + SEPARATOR_TOP + } else { + SEPARATOR_TOP_BOTTOM + }, + if has_title { + self.header_title.clone() + } else { + None + }, + if has_title { + Some(self.style.get_header_title_format()) + } else { + None + }, + ); + is_first_row = false; + has_title = false; + } + + if self.horizontal { + self.render_row( + Self::to_row_vec(row.clone()), + self.style.get_cell_row_format(), + Some(self.style.get_cell_header_format()), + ); + } else { + self.render_row( + Self::to_row_vec(row.clone()), + if is_header { + self.style.get_cell_header_format() + } else { + self.style.get_cell_row_format() + }, + None, + ); + } + } + } + self.render_row_separator( + SEPARATOR_BOTTOM, + self.footer_title.clone(), + Some(self.style.get_footer_title_format()), + ); + + self.cleanup(); + self.rendered = true; + } + + /// Renders horizontal header separator. + fn render_row_separator( + &self, + r#type: i64, + title: Option<String>, + title_format: Option<String>, + ) { + let count = match self.number_of_columns { + Some(0) | None => return, + Some(c) => c, + }; + + let borders = self.style.get_border_chars(); + if borders[0].is_empty() + && borders[2].is_empty() + && self.style.get_crossing_char().is_empty() + { + return; + } + + let crossings = self.style.get_crossing_chars(); + let (horizontal, left_char, mid_char, right_char) = if SEPARATOR_MID == r#type { + ( + borders[2].clone(), + crossings[8].clone(), + crossings[0].clone(), + crossings[4].clone(), + ) + } else if SEPARATOR_TOP == r#type { + ( + borders[0].clone(), + crossings[1].clone(), + crossings[2].clone(), + crossings[3].clone(), + ) + } else if SEPARATOR_TOP_BOTTOM == r#type { + ( + borders[0].clone(), + crossings[9].clone(), + crossings[10].clone(), + crossings[11].clone(), + ) + } else { + ( + borders[0].clone(), + crossings[7].clone(), + crossings[6].clone(), + crossings[5].clone(), + ) + }; + + let mut markup = left_char; + let mut column = 0; + while column < count { + markup.push_str(&shirabe_php_shim::str_repeat( + &horizontal, + self.effective_column_widths[&column] as usize, + )); + markup.push_str(if column == count - 1 { + &right_char + } else { + &mid_char + }); + column += 1; + } + + if let Some(title) = title { + let title_format = title_format.unwrap(); + let formatted_title = + shirabe_php_shim::sprintf(&title_format, &[PhpMixed::from(title.clone())]); + let mut formatted_title = formatted_title; + let mut title_length = Helper::width(&self.remove_decoration(&formatted_title)); + let markup_length = Helper::width(&markup); + let limit = markup_length - 4; + if title_length > limit { + title_length = limit; + let format_length = Helper::width(&self.remove_decoration( + &shirabe_php_shim::sprintf(&title_format, &[PhpMixed::from("")]), + )); + formatted_title = shirabe_php_shim::sprintf( + &title_format, + &[PhpMixed::from(format!( + "{}...", + Helper::substr(&title, 0, Some(limit - format_length - 3)) + ))], + ); + } + + let title_start = shirabe_php_shim::intdiv(markup_length - title_length, 2); + if shirabe_php_shim::mb_detect_encoding(&markup, None, true).is_none() { + markup = shirabe_php_shim::substr_replace( + &markup, + &formatted_title, + title_start as usize, + title_length as usize, + ); + } else { + markup = format!( + "{}{}{}", + shirabe_php_shim::mb_substr(&markup, 0, Some(title_start), None), + formatted_title, + shirabe_php_shim::mb_substr(&markup, title_start + title_length, None, None), + ); + } + } + + self.output.borrow().writeln( + &[shirabe_php_shim::sprintf( + &self.style.get_border_format(), + &[PhpMixed::from(markup)], + )], + crate::symfony::console::output::output_interface::OUTPUT_NORMAL, + ); + } + + /// Renders vertical column separator. + fn render_column_separator(&self, r#type: i64) -> String { + let borders = self.style.get_border_chars(); + + shirabe_php_shim::sprintf( + &self.style.get_border_format(), + &[PhpMixed::from(if BORDER_OUTSIDE == r#type { + borders[1].clone() + } else { + borders[3].clone() + })], + ) + } + + /// Renders table row. + fn render_row( + &self, + row: Vec<PhpMixed>, + cell_format: String, + first_cell_format: Option<String>, + ) { + let mut row_content = self.render_column_separator(BORDER_OUTSIDE); + let columns = self.get_row_columns(&row); + let last = columns.len() as i64 - 1; + for (i, column) in columns.into_iter().enumerate() { + let i = i as i64; + if first_cell_format.is_some() && 0 == i { + row_content.push_str(&self.render_cell( + &row, + column, + first_cell_format.clone().unwrap(), + )); + } else { + row_content.push_str(&self.render_cell(&row, column, cell_format.clone())); + } + row_content.push_str(&self.render_column_separator(if last == i { + BORDER_OUTSIDE + } else { + BORDER_INSIDE + })); + } + self.output.borrow().writeln( + &[row_content], + crate::symfony::console::output::output_interface::OUTPUT_NORMAL, + ); + } + + /// Renders table cell with padding. + fn render_cell(&self, row: &[PhpMixed], column: i64, cell_format: String) -> String { + let cell = Self::row_get_index(row, column).unwrap_or_else(|| PhpMixed::from("")); + let mut width = self.effective_column_widths[&column]; + if shirabe_php_shim::instance_of::<TableCell>(&cell) && Self::cell_colspan(&cell) > 1 { + // add the width of the following columns(numbers of colspan). + for next_column in (column + 1)..=(column + Self::cell_colspan(&cell) - 1) { + width += + self.get_column_separator_width() + self.effective_column_widths[&next_column]; + } + } + + // str_pad won't work properly with multi-byte strings, we need to fix the padding + let cell_str = shirabe_php_shim::to_string(&cell); + if let Some(encoding) = shirabe_php_shim::mb_detect_encoding(&cell_str, None, true) { + width += shirabe_php_shim::strlen(&cell_str) + - shirabe_php_shim::mb_strwidth(&cell_str, Some(&encoding)); + } + + let style = self.get_column_style(column).clone(); + + if shirabe_php_shim::instance_of::<TableSeparator>(&cell) { + return shirabe_php_shim::sprintf( + &style.get_border_format(), + &[PhpMixed::from(shirabe_php_shim::str_repeat( + &style.get_border_chars()[2], + width as usize, + ))], + ); + } + + width += Helper::length(&cell_str) - Helper::length(&self.remove_decoration(&cell_str)); + let mut content = shirabe_php_shim::sprintf( + &style.get_cell_row_content_format(), + &[PhpMixed::from(cell_str.clone())], + ); + + let mut cell_format = cell_format; + let mut pad_type = style.get_pad_type(); + if shirabe_php_shim::instance_of::<TableCell>(&cell) + && Self::cell_style_is_table_cell_style(&cell) + { + let is_not_styled_by_tag = !Preg::is_match( + "/^<(\\w+|(\\w+=[\\w,]+;?)*)>.+<\\/(\\w+|(\\w+=\\w+;?)*)?>$/", + &cell_str, + ) + .unwrap(); + if is_not_styled_by_tag { + let cell_style = Self::cell_get_style(&cell).unwrap(); + match cell_style.get_cell_format() { + Some(fmt) => cell_format = fmt, + None => { + let tag = shirabe_php_shim::http_build_query_mixed( + &cell_style.get_tag_options(), + "", + ";", + ); + cell_format = format!("<{}>%s</>", tag); + } + } + + if shirabe_php_shim::strstr(&content, "</>").is_some() { + content = shirabe_php_shim::str_replace("</>", "", &content); + width -= 3; + } + if shirabe_php_shim::strstr(&content, "<fg=default;bg=default>").is_some() { + content = + shirabe_php_shim::str_replace("<fg=default;bg=default>", "", &content); + width -= shirabe_php_shim::strlen("<fg=default;bg=default>"); + } + } + + pad_type = Self::cell_get_style(&cell).unwrap().get_pad_by_align(); + } + + shirabe_php_shim::sprintf( + &cell_format, + &[PhpMixed::from(shirabe_php_shim::str_pad( + &content, + width as usize, + &style.get_padding_char(), + pad_type, + ))], + ) + } + + /// Calculate number of columns for this table. + fn calculate_number_of_columns(&mut self, rows: &[PhpMixed]) { + let mut columns = vec![0i64]; + for row in rows { + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + continue; + } + + columns.push(self.get_number_of_columns(&Self::to_row_vec(row.clone()))); + } + + self.number_of_columns = Some(*columns.iter().max().unwrap()); + } + + fn build_table_rows(&mut self, rows: Vec<PhpMixed>) -> TableRows { + let mut rows = rows; + let mut unmerged_rows: IndexMap<i64, IndexMap<i64, Vec<PhpMixed>>> = IndexMap::new(); + let mut row_key = 0i64; + while row_key < rows.len() as i64 { + rows = self.fill_next_rows(rows, row_key); + + // Remove any new line breaks and replace it with a new line + let current = Self::to_row_vec(rows[row_key as usize].clone()); + for (column, cell) in current.iter().enumerate() { + let column = column as i64; + let mut cell = cell.clone(); + let colspan = if shirabe_php_shim::instance_of::<TableCell>(&cell) { + Self::cell_colspan(&cell) + } else { + 1 + }; + + if self.column_max_widths.contains_key(&column) + && Helper::width(&self.remove_decoration(&shirabe_php_shim::to_string(&cell))) + > self.column_max_widths[&column] + { + // TODO(phase-b): formatAndWrap requires a WrappableOutputFormatterInterface; + // downcasting dyn OutputFormatterInterface to it needs concrete knowledge. + let _ = colspan; + let wrapped: Option<String> = todo!(); + cell = PhpMixed::from(wrapped.unwrap_or_default()); + } + let cell_str = shirabe_php_shim::to_string(&cell); + if shirabe_php_shim::strstr(&cell_str, "\n").is_none() { + continue; + } + let eol = if shirabe_php_shim::str_contains(&cell_str, "\r\n") { + "\r\n" + } else { + "\n" + }; + let escaped = shirabe_php_shim::implode( + eol, + &shirabe_php_shim::explode(eol, &cell_str) + .iter() + .map(|line| OutputFormatter::escape_trailing_backslash(line)) + .collect::<Vec<_>>(), + ); + cell = if shirabe_php_shim::instance_of::<TableCell>(&cell) { + Self::table_cell_to_mixed(TableCell::new2( + &escaped, + Self::table_cell_options_colspan(Self::cell_colspan(&cell)), + )) + } else { + PhpMixed::from(escaped.clone()) + }; + let lines = shirabe_php_shim::explode( + eol, + &shirabe_php_shim::str_replace( + eol, + &format!("<fg=default;bg=default></>{}", eol), + &shirabe_php_shim::to_string(&cell), + ), + ); + for (line_key, line) in lines.into_iter().enumerate() { + let line_key = line_key as i64; + let mut line = PhpMixed::from(line); + if colspan > 1 { + line = Self::table_cell_to_mixed(TableCell::new2( + &shirabe_php_shim::to_string(&line), + Self::table_cell_options_colspan(colspan), + )); + } + if 0 == line_key { + let mut r = Self::to_row_vec(rows[row_key as usize].clone()); + Self::array_set(&mut r, column, line); + rows[row_key as usize] = Self::from_row_vec(r); + } else { + if !unmerged_rows.contains_key(&row_key) + || !unmerged_rows[&row_key].contains_key(&line_key) + { + let copied = self.copy_row(&rows, row_key); + unmerged_rows + .entry(row_key) + .or_default() + .insert(line_key, copied); + } + let target = unmerged_rows + .get_mut(&row_key) + .unwrap() + .get_mut(&line_key) + .unwrap(); + Self::vec_set(target, column, line); + } + } + } + row_key += 1; + } + + // PHP returns a TableRows wrapping a generator that lazily yields row groups. + // The generator borrows $this to call fillCells(). In Phase A we precompute the + // row groups eagerly to preserve behavior, then hand them to TableRows. + let mut row_groups: Vec<Vec<PhpMixed>> = Vec::new(); + for (row_key, row) in rows.into_iter().enumerate() { + let row_key = row_key as i64; + let mut row_group: Vec<PhpMixed> = + vec![if shirabe_php_shim::instance_of::<TableSeparator>(&row) { + row + } else { + Self::from_row_vec(self.fill_cells(Self::to_row_vec(row))) + }]; + + if let Some(extra) = unmerged_rows.get(&row_key) { + for r in extra.values() { + let r = Self::from_row_vec(r.clone()); + row_group.push(if shirabe_php_shim::instance_of::<TableSeparator>(&r) { + r + } else { + Self::from_row_vec(self.fill_cells(Self::to_row_vec(r))) + }); + } + } + row_groups.push(row_group); + } + + TableRows::from_row_groups(row_groups) + } + + fn calculate_row_count(&mut self) -> i64 { + let mut merged = self.headers.clone(); + merged.push(Self::table_separator_to_mixed(TableSeparator::new())); + merged.extend(self.rows.clone()); + let mut number_of_rows = + shirabe_php_shim::iterator_to_array(self.build_table_rows(merged)).len() as i64; + + if !self.headers.is_empty() { + number_of_rows += 1; // Add row for header separator + } + + if !self.rows.is_empty() { + number_of_rows += 1; // Add row for footer separator + } + + number_of_rows + } + + /// fill rows that contains rowspan > 1. + fn fill_next_rows(&self, rows: Vec<PhpMixed>, line: i64) -> Vec<PhpMixed> { + let mut rows = rows; + let mut unmerged_rows: IndexMap<i64, IndexMap<i64, PhpMixed>> = IndexMap::new(); + let current = Self::to_row_vec(rows[line as usize].clone()); + for (column, cell) in current.iter().enumerate() { + let column = column as i64; + let cell = cell.clone(); + if !shirabe_php_shim::is_null(&cell) + && !shirabe_php_shim::instance_of::<TableCell>(&cell) + && !shirabe_php_shim::is_scalar(&cell) + && !(shirabe_php_shim::is_object(&cell) + && shirabe_php_shim::method_exists(&cell, "__toString")) + { + // PHP throws InvalidArgumentException; the @throws contract makes this a + // recoverable error. In Phase A we keep the panic placeholder as fill_next_rows + // does not yet return a Result in the call chain. + // TODO(phase-b): thread InvalidArgumentException through fill_next_rows. + panic!( + "A cell must be a TableCell, a scalar or an object implementing \"__toString()\", \"{}\" given.", + shirabe_php_shim::get_debug_type(&cell) + ); + } + if shirabe_php_shim::instance_of::<TableCell>(&cell) && Self::cell_rowspan(&cell) > 1 { + let mut nb_lines = Self::cell_rowspan(&cell) - 1; + let cell_str = shirabe_php_shim::to_string(&cell); + let mut lines = vec![cell.clone()]; + if shirabe_php_shim::strstr(&cell_str, "\n").is_some() { + let eol = if shirabe_php_shim::str_contains(&cell_str, "\r\n") { + "\r\n" + } else { + "\n" + }; + let exploded = shirabe_php_shim::explode( + eol, + &shirabe_php_shim::str_replace( + eol, + &format!("<fg=default;bg=default>{}</>", eol), + &cell_str, + ), + ); + lines = exploded.into_iter().map(PhpMixed::from).collect(); + nb_lines = if (lines.len() as i64) > nb_lines { + shirabe_php_shim::substr_count(&cell_str, eol) + } else { + nb_lines + }; + + let mut r = Self::to_row_vec(rows[line as usize].clone()); + Self::array_set( + &mut r, + column, + Self::table_cell_to_mixed(TableCell::new2( + &shirabe_php_shim::to_string(&lines[0]), + Self::table_cell_options_colspan_style( + Self::cell_colspan(&cell), + Self::cell_get_style(&cell), + ), + )), + ); + rows[line as usize] = Self::from_row_vec(r); + lines.remove(0); + } + + // create a two dimensional array (rowspan x colspan) + for k in (line + 1)..=(line + nb_lines) { + unmerged_rows.entry(k).or_default(); + } + for unmerged_row_key in unmerged_rows.keys().cloned().collect::<Vec<_>>() { + let idx = unmerged_row_key - line; + let value = lines + .get(idx as usize) + .cloned() + .unwrap_or_else(|| PhpMixed::from("")); + unmerged_rows.get_mut(&unmerged_row_key).unwrap().insert( + column, + Self::table_cell_to_mixed(TableCell::new2( + &shirabe_php_shim::to_string(&value), + Self::table_cell_options_colspan_style( + Self::cell_colspan(&cell), + Self::cell_get_style(&cell), + ), + )), + ); + if nb_lines == unmerged_row_key - line { + break; + } + } + } + } + + for (unmerged_row_key, unmerged_row) in unmerged_rows.clone() { + // we need to know if $unmergedRow will be merged or inserted into $rows + let fits = (unmerged_row_key as usize) < rows.len() + && shirabe_php_shim::is_array(&rows[unmerged_row_key as usize]) + && (self.get_number_of_columns(&Self::to_row_vec( + rows[unmerged_row_key as usize].clone(), + )) + self.get_number_of_columns( + &unmerged_rows[&unmerged_row_key] + .values() + .cloned() + .collect::<Vec<_>>(), + ) <= self.number_of_columns.unwrap()); + if fits { + let mut target = Self::to_row_vec(rows[unmerged_row_key as usize].clone()); + for (cell_key, cell) in unmerged_row { + // insert cell into row at cellKey position + shirabe_php_shim::array_splice(&mut target, cell_key, Some(0), vec![cell]); + } + rows[unmerged_row_key as usize] = Self::from_row_vec(target); + } else { + let mut row = self.copy_row(&rows, unmerged_row_key - 1); + for (column, cell) in &unmerged_row { + if shirabe_php_shim::to_bool(cell) { + Self::vec_set(&mut row, *column, unmerged_row[column].clone()); + } + } + shirabe_php_shim::array_splice_mixed( + &mut rows, + unmerged_row_key, + 0, + vec![Self::from_row_vec(row)], + ); + } + } + + rows + } + + /// fill cells for a row that contains colspan > 1. + fn fill_cells(&self, row: Vec<PhpMixed>) -> Vec<PhpMixed> { + let mut new_row: Vec<PhpMixed> = Vec::new(); + + for (column, cell) in row.iter().enumerate() { + let column = column as i64; + new_row.push(cell.clone()); + if shirabe_php_shim::instance_of::<TableCell>(cell) && Self::cell_colspan(cell) > 1 { + for _position in (column + 1)..=(column + Self::cell_colspan(cell) - 1) { + // insert empty value at column position + new_row.push(PhpMixed::from("")); + } + } + } + + if new_row.is_empty() { row } else { new_row } + } + + fn copy_row(&self, rows: &[PhpMixed], line: i64) -> Vec<PhpMixed> { + let mut row = Self::to_row_vec(rows[line as usize].clone()); + for cell_key in 0..row.len() { + let cell_value = row[cell_key].clone(); + row[cell_key] = PhpMixed::from(""); + if shirabe_php_shim::instance_of::<TableCell>(&cell_value) { + row[cell_key] = Self::table_cell_to_mixed(TableCell::new2( + "", + Self::table_cell_options_colspan(Self::cell_colspan(&cell_value)), + )); + } + } + + row + } + + /// Gets number of columns by row. + fn get_number_of_columns(&self, row: &[PhpMixed]) -> i64 { + let mut columns = row.len() as i64; + for column in row { + columns += if shirabe_php_shim::instance_of::<TableCell>(column) { + Self::cell_colspan(column) - 1 + } else { + 0 + }; + } + + columns + } + + /// Gets list of columns for the given row. + fn get_row_columns(&self, row: &[PhpMixed]) -> Vec<i64> { + let mut columns: Vec<i64> = (0..self.number_of_columns.unwrap()).collect(); + for (cell_key, cell) in row.iter().enumerate() { + let cell_key = cell_key as i64; + if shirabe_php_shim::instance_of::<TableCell>(cell) && Self::cell_colspan(cell) > 1 { + // exclude grouped columns. + let excluded: Vec<i64> = + ((cell_key + 1)..=(cell_key + Self::cell_colspan(cell) - 1)).collect(); + columns.retain(|c| !excluded.contains(c)); + } + } + + columns + } + + /// Calculates columns widths. + fn calculate_columns_width(&mut self, groups: &TableRows) { + let mut column = 0; + while column < self.number_of_columns.unwrap() { + let mut lengths: Vec<i64> = Vec::new(); + for group in groups { + for row in group { + if shirabe_php_shim::instance_of::<TableSeparator>(row) { + continue; + } + + let mut row_arr = Self::to_row_vec(row.clone()); + for i in 0..row_arr.len() { + let cell = row_arr[i].clone(); + if shirabe_php_shim::instance_of::<TableCell>(&cell) { + let text_content = + self.remove_decoration(&shirabe_php_shim::to_string(&cell)); + let text_length = Helper::width(&text_content); + if text_length > 0 { + let content_columns = shirabe_php_shim::mb_str_split( + &text_content, + shirabe_php_shim::ceil( + text_length as f64 / Self::cell_colspan(&cell) as f64, + ) as i64, + ); + for (position, content) in content_columns.into_iter().enumerate() { + Self::vec_set( + &mut row_arr, + i as i64 + position as i64, + PhpMixed::from(content), + ); + } + } + } + } + + lengths.push(self.get_cell_width(&row_arr, column)); + } + } + + self.effective_column_widths.insert( + column, + *lengths.iter().max().unwrap() + + Helper::width(&self.style.get_cell_row_content_format()) + - 2, + ); + column += 1; + } + } + + fn get_column_separator_width(&self) -> i64 { + Helper::width(&shirabe_php_shim::sprintf( + &self.style.get_border_format(), + &[PhpMixed::from(self.style.get_border_chars()[3].clone())], + )) + } + + fn get_cell_width(&self, row: &[PhpMixed], column: i64) -> i64 { + let mut cell_width = 0; + + if let Some(cell) = Self::row_get_index(row, column) { + cell_width = + Helper::width(&self.remove_decoration(&shirabe_php_shim::to_string(&cell))); + } + + let column_width = *self.column_widths.get(&column).unwrap_or(&0); + cell_width = cell_width.max(column_width); + + if let Some(max) = self.column_max_widths.get(&column) { + (*max).min(cell_width) + } else { + cell_width + } + } + + /// Called after rendering to cleanup cache data. + fn cleanup(&mut self) { + self.effective_column_widths = IndexMap::new(); + self.number_of_columns = None; + } + + fn init_styles() -> IndexMap<String, TableStyle> { + let mut borderless = TableStyle::default(); + borderless + .set_horizontal_border_chars("=".to_string(), None) + .set_vertical_border_chars(" ".to_string(), None) + .set_default_crossing_char(" ".to_string()); + + let mut compact = TableStyle::default(); + compact + .set_horizontal_border_chars("".to_string(), None) + .set_vertical_border_chars("".to_string(), None) + .set_default_crossing_char("".to_string()) + .set_cell_row_content_format("%s ".to_string()); + + let mut style_guide = TableStyle::default(); + style_guide + .set_horizontal_border_chars("-".to_string(), None) + .set_vertical_border_chars(" ".to_string(), None) + .set_default_crossing_char(" ".to_string()) + .set_cell_header_format("%s".to_string()); + + let mut r#box = TableStyle::default(); + r#box + .set_horizontal_border_chars("─".to_string(), None) + .set_vertical_border_chars("│".to_string(), None) + .set_crossing_chars( + "┼".to_string(), + "┌".to_string(), + "┬".to_string(), + "┐".to_string(), + "┤".to_string(), + "┘".to_string(), + "┴".to_string(), + "└".to_string(), + "├".to_string(), + None, + None, + None, + ); + + let mut box_double = TableStyle::default(); + box_double + .set_horizontal_border_chars("═".to_string(), Some("─".to_string())) + .set_vertical_border_chars("║".to_string(), Some("│".to_string())) + .set_crossing_chars( + "┼".to_string(), + "╔".to_string(), + "╤".to_string(), + "╗".to_string(), + "╢".to_string(), + "╝".to_string(), + "╧".to_string(), + "╚".to_string(), + "╟".to_string(), + Some("╠".to_string()), + Some("╪".to_string()), + Some("╣".to_string()), + ); + + let mut result: IndexMap<String, TableStyle> = IndexMap::new(); + result.insert("default".to_string(), TableStyle::default()); + result.insert("borderless".to_string(), borderless); + result.insert("compact".to_string(), compact); + result.insert("symfony-style-guide".to_string(), style_guide); + result.insert("box".to_string(), r#box); + result.insert("box-double".to_string(), box_double); + + result + } + + fn resolve_style( + &self, + name: PhpMixed, + ) -> anyhow::Result<Result<TableStyle, InvalidArgumentException>> { + if shirabe_php_shim::instance_of::<TableStyle>(&name) { + // TODO(phase-b): extract the owned TableStyle out of PhpMixed. + todo!() + } + + let name_str = shirabe_php_shim::to_string(&name); + let styles_guard = styles().lock().unwrap(); + if let Some(_style) = styles_guard.as_ref().and_then(|s| s.get(&name_str)) { + // TODO(phase-b): TableStyle is not Clone; sharing semantics need resolving. + todo!() + } + + Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Style \"%s\" is not defined.", + &[PhpMixed::from(name_str)], + ), + code: 0, + }, + ))) + } + + // --- Phase A helpers for `mixed`/array semantics over PhpMixed ------------------------------- + // + // These bridge PHP's dynamic typing (a cell is a TableCell, a scalar, null, or an array; + // a row is an array or a TableSeparator) onto PhpMixed. Their bodies are deferred to Phase B + // where the concrete PhpMixed representation is settled. + + fn formatter_is_wrappable(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { + // PHP: $this->output->getFormatter() instanceof WrappableOutputFormatterInterface + // TODO(phase-b): trait-to-trait instanceof check requires concrete formatter knowledge. + let _ = std::any::type_name::<dyn WrappableOutputFormatterInterface>(); + todo!() + } + + /// PHP `Helper::removeDecoration($this->output->getFormatter(), $string)`. + fn remove_decoration(&self, string: &str) -> String { + let formatter = self.output.borrow().get_formatter(); + let mut formatter = formatter.borrow_mut(); + Helper::remove_decoration(&mut *formatter, string) + } + + fn output_is_console_section(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool { + // PHP: $this->output instanceof ConsoleSectionOutput todo!() } - pub fn set_rows(&mut self, _rows: Vec<PhpMixed>) -> &mut Self { + fn is_divider(_row: &PhpMixed, _divider: &TableSeparator) -> bool { + // PHP: $divider === $row (object identity) todo!() } - pub fn add_row(&mut self, _row: PhpMixed) -> &mut Self { + fn cell_colspan(_cell: &PhpMixed) -> i64 { + // PHP: $cell->getColspan() todo!() } - pub fn render(&mut self) { + fn cell_rowspan(_cell: &PhpMixed) -> i64 { + // PHP: $cell->getRowspan() + todo!() + } + + fn cell_get_style(_cell: &PhpMixed) -> Option<TableCellStyle> { + // PHP: $cell->getStyle() + todo!() + } + + fn cell_style_is_table_cell_style(_cell: &PhpMixed) -> bool { + // PHP: $cell->getStyle() instanceof TableCellStyle + todo!() + } + + fn table_cell_options_colspan(_colspan: i64) -> IndexMap<String, TableCellOption> { + // PHP: ['colspan' => $colspan] + todo!() + } + + fn table_cell_options_colspan_style( + _colspan: i64, + _style: Option<TableCellStyle>, + ) -> IndexMap<String, TableCellOption> { + // PHP: ['colspan' => $colspan, 'style' => $style] + todo!() + } + + fn row_get(_row: &PhpMixed, _index: i64) -> Option<PhpMixed> { + // PHP: isset($row[$i]) ? $row[$i] : null, where $row is an array-typed PhpMixed + todo!() + } + + fn row_get_index(_row: &[PhpMixed], _index: i64) -> Option<PhpMixed> { + // PHP: $row[$column] ?? null, honoring sparse integer keys + todo!() + } + + fn array_set(_row: &mut [PhpMixed], _index: i64, _value: PhpMixed) { + // PHP: $row[$index] = $value (sparse assignment) todo!() } - pub fn set_style(&mut self, _style: &str) -> &mut Self { + fn vec_set(_row: &mut Vec<PhpMixed>, _index: i64, _value: PhpMixed) { + // PHP: $row[$index] = $value (sparse assignment) todo!() } - pub fn set_column_width(&mut self, _column_index: usize, _width: i64) -> &mut Self { + /// PHP rows/cells are integer-keyed arrays. We model a row as a positional + /// `Vec<PhpMixed>`. A cell that is a TableCell/TableSeparator cannot be carried by + /// PhpMixed (php-shim limitation), so such conversions are deferred. + fn to_row_vec(_row: PhpMixed) -> Vec<PhpMixed> { + // PHP: an array-typed value seen as a list of cells. todo!() } - pub fn set_column_widths(&mut self, _widths: Vec<i64>) -> &mut Self { + fn from_row_vec(_row: Vec<PhpMixed>) -> PhpMixed { + // PHP: a list of cells seen as an array-typed value. todo!() } - pub fn set_column_max_width(&mut self, _column_index: usize, _width: i64) -> &mut Self { + /// PhpMixed cannot hold console value objects (TableCell/TableSeparator). The cell + /// representation is deferred to a later phase. + fn table_cell_to_mixed(_cell: TableCell) -> PhpMixed { todo!() } - pub fn set_horizontal(&mut self, _horizontal: bool) -> &mut Self { + fn table_separator_to_mixed(_separator: TableSeparator) -> PhpMixed { todo!() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_cell.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell.rs new file mode 100644 index 0000000..d8d8339 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell.rs @@ -0,0 +1,113 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::table_cell_style::TableCellStyle; +use indexmap::IndexMap; +use std::rc::Rc; + +/// A `TableCell` option value: an integer span, a `TableCellStyle`, or null. +#[derive(Debug, Clone)] +pub enum TableCellOption { + Int(i64), + Style(Rc<TableCellStyle>), + Null, +} + +#[derive(Debug, Clone)] +pub struct TableCell { + pub(crate) value: String, + options: IndexMap<String, TableCellOption>, +} + +impl TableCell { + pub fn new( + value: &str, + options: IndexMap<String, TableCellOption>, + ) -> Result<Self, InvalidArgumentException> { + let mut this_options: IndexMap<String, TableCellOption> = IndexMap::new(); + this_options.insert("rowspan".to_string(), TableCellOption::Int(1)); + this_options.insert("colspan".to_string(), TableCellOption::Int(1)); + this_options.insert("style".to_string(), TableCellOption::Null); + + // check option names + let diff: Vec<String> = options + .keys() + .filter(|key| !this_options.contains_key(*key)) + .cloned() + .collect(); + if !diff.is_empty() { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The TableCell does not support the following options: '%s'.", + &[shirabe_php_shim::PhpMixed::String(diff.join("', '"))], + ), + code: 0, + }, + )); + } + + if let Some(style) = options.get("style") { + if !matches!(style, TableCellOption::Style(_)) + && !matches!(style, TableCellOption::Null) + { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "The style option must be an instance of \"TableCellStyle\"." + .to_string(), + code: 0, + }, + )); + } + } + + for (key, option) in options { + this_options.insert(key, option); + } + + Ok(Self { + value: value.to_string(), + options: this_options, + }) + } + + /// Two-argument constructor (`__construct(string $value, array $options)`). + /// + /// The options used by the Table helper are internally controlled, so a malformed-option + /// error here would be a programming bug rather than a recoverable condition. + pub fn new2(value: &str, options: IndexMap<String, TableCellOption>) -> Self { + Self::new(value, options).expect("TableCell options built internally are always valid") + } + + /// Returns the cell value. + pub fn to_string(&self) -> String { + self.value.clone() + } + + /// Gets number of colspan. + pub fn get_colspan(&self) -> i64 { + match self.options["colspan"] { + TableCellOption::Int(colspan) => colspan, + _ => 0, + } + } + + /// Gets number of rowspan. + pub fn get_rowspan(&self) -> i64 { + match self.options["rowspan"] { + TableCellOption::Int(rowspan) => rowspan, + _ => 0, + } + } + + pub fn get_style(&self) -> Option<Rc<TableCellStyle>> { + match &self.options["style"] { + TableCellOption::Style(style) => Some(style.clone()), + _ => None, + } + } +} + +impl std::fmt::Display for TableCell { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_cell_style.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell_style.rs new file mode 100644 index 0000000..9d41c45 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_cell_style.rs @@ -0,0 +1,126 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use indexmap::IndexMap; + +pub const DEFAULT_ALIGN: &str = "left"; + +const TAG_OPTIONS: [&str; 3] = ["fg", "bg", "options"]; + +/// Maps an alignment name to the corresponding `STR_PAD_*` value. +fn align_map(align: &str) -> Option<i64> { + match align { + "left" => Some(shirabe_php_shim::STR_PAD_RIGHT), + "center" => Some(shirabe_php_shim::STR_PAD_BOTH), + "right" => Some(shirabe_php_shim::STR_PAD_LEFT), + _ => None, + } +} + +fn align_map_keys() -> Vec<&'static str> { + vec!["left", "center", "right"] +} + +#[derive(Debug)] +pub struct TableCellStyle { + options: IndexMap<String, shirabe_php_shim::PhpMixed>, +} + +impl TableCellStyle { + pub fn new( + options: IndexMap<String, shirabe_php_shim::PhpMixed>, + ) -> Result<Self, InvalidArgumentException> { + let mut this_options: IndexMap<String, shirabe_php_shim::PhpMixed> = IndexMap::new(); + this_options.insert( + "fg".to_string(), + shirabe_php_shim::PhpMixed::String("default".to_string()), + ); + this_options.insert( + "bg".to_string(), + shirabe_php_shim::PhpMixed::String("default".to_string()), + ); + this_options.insert("options".to_string(), shirabe_php_shim::PhpMixed::Null); + this_options.insert( + "align".to_string(), + shirabe_php_shim::PhpMixed::String(DEFAULT_ALIGN.to_string()), + ); + this_options.insert("cellFormat".to_string(), shirabe_php_shim::PhpMixed::Null); + + let diff: Vec<String> = options + .keys() + .filter(|key| !this_options.contains_key(*key)) + .cloned() + .collect(); + if !diff.is_empty() { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The TableCellStyle does not support the following options: '%s'.", + &[shirabe_php_shim::PhpMixed::String(diff.join("', '"))], + ), + code: 0, + }, + )); + } + + if let Some(align) = options.get("align") { + let align = match align { + shirabe_php_shim::PhpMixed::String(align) => align.clone(), + _ => String::new(), + }; + if align_map(&align).is_none() { + return Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Wrong align value. Value must be following: '%s'.", + &[shirabe_php_shim::PhpMixed::String( + align_map_keys().join("', '"), + )], + ), + code: 0, + }, + )); + } + } + + for (key, value) in options { + this_options.insert(key, value); + } + + Ok(Self { + options: this_options, + }) + } + + pub fn get_options(&self) -> IndexMap<String, shirabe_php_shim::PhpMixed> { + self.options.clone() + } + + /// Gets options we need for tag for example fg, bg. + /// + /// @return string[] + pub fn get_tag_options(&self) -> IndexMap<String, shirabe_php_shim::PhpMixed> { + let mut result: IndexMap<String, shirabe_php_shim::PhpMixed> = IndexMap::new(); + for (key, value) in self.get_options() { + if TAG_OPTIONS.contains(&key.as_str()) + && !matches!(self.options[&key], shirabe_php_shim::PhpMixed::Null) + { + result.insert(key, value); + } + } + result + } + + pub fn get_pad_by_align(&self) -> i64 { + let align = match &self.get_options()["align"] { + shirabe_php_shim::PhpMixed::String(align) => align.clone(), + _ => String::new(), + }; + align_map(&align).unwrap() + } + + pub fn get_cell_format(&self) -> Option<String> { + match &self.get_options()["cellFormat"] { + shirabe_php_shim::PhpMixed::String(format) => Some(format.clone()), + _ => None, + } + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_rows.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_rows.rs new file mode 100644 index 0000000..c8394df --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_rows.rs @@ -0,0 +1,43 @@ +use shirabe_php_shim::PhpMixed; + +/// @internal +/// +/// In PHP this wraps a `\Closure` yielding a `\Traversable` of row groups. The generator +/// borrows the Table to lazily call `fillCells()`. For the Rust port we precompute the row +/// groups eagerly (see `Table::build_table_rows`) and store them here. +#[derive(Debug)] +pub struct TableRows { + row_groups: Vec<Vec<PhpMixed>>, +} + +impl TableRows { + pub fn from_row_groups(row_groups: Vec<Vec<PhpMixed>>) -> Self { + Self { row_groups } + } + + pub fn get_iterator(&self) -> std::slice::Iter<'_, Vec<PhpMixed>> { + self.row_groups.iter() + } + + pub fn into_row_groups(self) -> Vec<Vec<PhpMixed>> { + self.row_groups + } +} + +impl<'a> IntoIterator for &'a TableRows { + type Item = &'a Vec<PhpMixed>; + type IntoIter = std::slice::Iter<'a, Vec<PhpMixed>>; + + fn into_iter(self) -> Self::IntoIter { + self.row_groups.iter() + } +} + +impl IntoIterator for TableRows { + type Item = Vec<PhpMixed>; + type IntoIter = std::vec::IntoIter<Vec<PhpMixed>>; + + fn into_iter(self) -> Self::IntoIter { + self.row_groups.into_iter() + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs index 22ac013..9dac411 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_separator.rs @@ -1,14 +1,29 @@ -#[derive(Debug)] -pub struct TableSeparator; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::helper::table_cell::{TableCell, TableCellOption}; +use indexmap::IndexMap; -impl Default for TableSeparator { - fn default() -> Self { - Self::new() - } +/// Marks a row as being a separator. +#[derive(Debug, Clone)] +pub struct TableSeparator { + inner: TableCell, } impl TableSeparator { pub fn new() -> Self { - todo!() + Self::new1(IndexMap::new()).expect("TableSeparator default options are always valid") + } + + pub fn new1( + options: IndexMap<String, TableCellOption>, + ) -> Result<Self, InvalidArgumentException> { + Ok(Self { + inner: TableCell::new("", options)?, + }) + } +} + +impl Default for TableSeparator { + fn default() -> Self { + Self::new() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table_style.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table_style.rs new file mode 100644 index 0000000..5820f15 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table_style.rs @@ -0,0 +1,293 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; + +/// Defines the styles for a Table. +#[derive(Debug)] +pub struct TableStyle { + padding_char: String, + horizontal_outside_border_char: String, + horizontal_inside_border_char: String, + vertical_outside_border_char: String, + vertical_inside_border_char: String, + crossing_char: String, + crossing_top_right_char: String, + crossing_top_mid_char: String, + crossing_top_left_char: String, + crossing_mid_right_char: String, + crossing_bottom_right_char: String, + crossing_bottom_mid_char: String, + crossing_bottom_left_char: String, + crossing_mid_left_char: String, + crossing_top_left_bottom_char: String, + crossing_top_mid_bottom_char: String, + crossing_top_right_bottom_char: String, + header_title_format: String, + footer_title_format: String, + cell_header_format: String, + cell_row_format: String, + cell_row_content_format: String, + border_format: String, + pad_type: i64, +} + +impl Default for TableStyle { + fn default() -> Self { + Self { + padding_char: " ".to_string(), + horizontal_outside_border_char: "-".to_string(), + horizontal_inside_border_char: "-".to_string(), + vertical_outside_border_char: "|".to_string(), + vertical_inside_border_char: "|".to_string(), + crossing_char: "+".to_string(), + crossing_top_right_char: "+".to_string(), + crossing_top_mid_char: "+".to_string(), + crossing_top_left_char: "+".to_string(), + crossing_mid_right_char: "+".to_string(), + crossing_bottom_right_char: "+".to_string(), + crossing_bottom_mid_char: "+".to_string(), + crossing_bottom_left_char: "+".to_string(), + crossing_mid_left_char: "+".to_string(), + crossing_top_left_bottom_char: "+".to_string(), + crossing_top_mid_bottom_char: "+".to_string(), + crossing_top_right_bottom_char: "+".to_string(), + header_title_format: "<fg=black;bg=white;options=bold> %s </>".to_string(), + footer_title_format: "<fg=black;bg=white;options=bold> %s </>".to_string(), + cell_header_format: "<info>%s</info>".to_string(), + cell_row_format: "%s".to_string(), + cell_row_content_format: " %s ".to_string(), + border_format: "%s".to_string(), + pad_type: shirabe_php_shim::STR_PAD_RIGHT, + } + } +} + +impl TableStyle { + /// Sets padding character, used for cell padding. + pub fn set_padding_char( + &mut self, + padding_char: String, + ) -> anyhow::Result<Result<&mut Self, LogicException>> { + if padding_char.is_empty() { + return Ok(Err(LogicException(shirabe_php_shim::LogicException { + message: "The padding char must not be empty.".to_string(), + code: 0, + }))); + } + + self.padding_char = padding_char; + + Ok(Ok(self)) + } + + /// Gets padding character, used for cell padding. + pub fn get_padding_char(&self) -> String { + self.padding_char.clone() + } + + /// Sets horizontal border characters. + pub fn set_horizontal_border_chars( + &mut self, + outside: String, + inside: Option<String>, + ) -> &mut Self { + self.horizontal_outside_border_char = outside.clone(); + self.horizontal_inside_border_char = inside.unwrap_or(outside); + + self + } + + /// Sets vertical border characters. + pub fn set_vertical_border_chars( + &mut self, + outside: String, + inside: Option<String>, + ) -> &mut Self { + self.vertical_outside_border_char = outside.clone(); + self.vertical_inside_border_char = inside.unwrap_or(outside); + + self + } + + /// Gets border characters. + pub fn get_border_chars(&self) -> Vec<String> { + vec![ + self.horizontal_outside_border_char.clone(), + self.vertical_outside_border_char.clone(), + self.horizontal_inside_border_char.clone(), + self.vertical_inside_border_char.clone(), + ] + } + + /// Sets crossing characters. + #[allow(clippy::too_many_arguments)] + pub fn set_crossing_chars( + &mut self, + cross: String, + top_left: String, + top_mid: String, + top_right: String, + mid_right: String, + bottom_right: String, + bottom_mid: String, + bottom_left: String, + mid_left: String, + top_left_bottom: Option<String>, + top_mid_bottom: Option<String>, + top_right_bottom: Option<String>, + ) -> &mut Self { + self.crossing_char = cross.clone(); + self.crossing_top_left_char = top_left; + self.crossing_top_mid_char = top_mid; + self.crossing_top_right_char = top_right; + self.crossing_mid_right_char = mid_right.clone(); + self.crossing_bottom_right_char = bottom_right; + self.crossing_bottom_mid_char = bottom_mid; + self.crossing_bottom_left_char = bottom_left; + self.crossing_mid_left_char = mid_left.clone(); + self.crossing_top_left_bottom_char = top_left_bottom.unwrap_or(mid_left); + self.crossing_top_mid_bottom_char = top_mid_bottom.unwrap_or(cross); + self.crossing_top_right_bottom_char = top_right_bottom.unwrap_or(mid_right); + + self + } + + /// Sets default crossing character used for each cross. + pub fn set_default_crossing_char(&mut self, char: String) -> &mut Self { + self.set_crossing_chars( + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char.clone(), + char, + None, + None, + None, + ) + } + + /// Gets crossing character. + pub fn get_crossing_char(&self) -> String { + self.crossing_char.clone() + } + + /// Gets crossing characters. + pub fn get_crossing_chars(&self) -> Vec<String> { + vec![ + self.crossing_char.clone(), + self.crossing_top_left_char.clone(), + self.crossing_top_mid_char.clone(), + self.crossing_top_right_char.clone(), + self.crossing_mid_right_char.clone(), + self.crossing_bottom_right_char.clone(), + self.crossing_bottom_mid_char.clone(), + self.crossing_bottom_left_char.clone(), + self.crossing_mid_left_char.clone(), + self.crossing_top_left_bottom_char.clone(), + self.crossing_top_mid_bottom_char.clone(), + self.crossing_top_right_bottom_char.clone(), + ] + } + + /// Sets header cell format. + pub fn set_cell_header_format(&mut self, cell_header_format: String) -> &mut Self { + self.cell_header_format = cell_header_format; + + self + } + + /// Gets header cell format. + pub fn get_cell_header_format(&self) -> String { + self.cell_header_format.clone() + } + + /// Sets row cell format. + pub fn set_cell_row_format(&mut self, cell_row_format: String) -> &mut Self { + self.cell_row_format = cell_row_format; + + self + } + + /// Gets row cell format. + pub fn get_cell_row_format(&self) -> String { + self.cell_row_format.clone() + } + + /// Sets row cell content format. + pub fn set_cell_row_content_format(&mut self, cell_row_content_format: String) -> &mut Self { + self.cell_row_content_format = cell_row_content_format; + + self + } + + /// Gets row cell content format. + pub fn get_cell_row_content_format(&self) -> String { + self.cell_row_content_format.clone() + } + + /// Sets table border format. + pub fn set_border_format(&mut self, border_format: String) -> &mut Self { + self.border_format = border_format; + + self + } + + /// Gets table border format. + pub fn get_border_format(&self) -> String { + self.border_format.clone() + } + + /// Sets cell padding type. + pub fn set_pad_type( + &mut self, + pad_type: i64, + ) -> anyhow::Result<Result<&mut Self, InvalidArgumentException>> { + if ![ + shirabe_php_shim::STR_PAD_LEFT, + shirabe_php_shim::STR_PAD_RIGHT, + shirabe_php_shim::STR_PAD_BOTH, + ] + .contains(&pad_type) + { + return Ok(Err(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: "Invalid padding type. Expected one of (STR_PAD_LEFT, STR_PAD_RIGHT, STR_PAD_BOTH)." + .to_string(), + code: 0, + }, + ))); + } + + self.pad_type = pad_type; + + Ok(Ok(self)) + } + + /// Gets cell padding type. + pub fn get_pad_type(&self) -> i64 { + self.pad_type + } + + pub fn get_header_title_format(&self) -> String { + self.header_title_format.clone() + } + + pub fn set_header_title_format(&mut self, format: String) -> &mut Self { + self.header_title_format = format; + + self + } + + pub fn get_footer_title_format(&self) -> String { + self.footer_title_format.clone() + } + + pub fn set_footer_title_format(&mut self, format: String) -> &mut Self { + self.footer_title_format = format; + + self + } +} |
