aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-symfony-console/src/helper
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-09 11:19:03 +0900
committernsfisis <nsfisis@gmail.com>2026-08-09 11:19:03 +0900
commit6051927c8fa32cfffa102d2a170c5a6cf747a1b9 (patch)
tree3fe6769c90cb03ca8f1b9b825e38ad459182361e /crates/shirabe-symfony-console/src/helper
parente3e8806aec771e482899ed3470e920f7b291fa95 (diff)
downloadphp-shirabe-6051927c8fa32cfffa102d2a170c5a6cf747a1b9.tar.gz
php-shirabe-6051927c8fa32cfffa102d2a170c5a6cf747a1b9.tar.zst
php-shirabe-6051927c8fa32cfffa102d2a170c5a6cf747a1b9.zip
refactor(symfony-console): extract symfony/console into the shirabe-symfony-console crate
Move `Symfony\Component\Console` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_console::application::Application` instead of `shirabe_external_packages::symfony::console::application::Application`. The `delegate_to_inner!` and `delegate_command_trait_impls_to_inner!` macros move with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-symfony-console/src/helper')
-rw-r--r--crates/shirabe-symfony-console/src/helper/debug_formatter_helper.rs175
-rw-r--r--crates/shirabe-symfony-console/src/helper/descriptor_helper.rs119
-rw-r--r--crates/shirabe-symfony-console/src/helper/formatter_helper.rs99
-rw-r--r--crates/shirabe-symfony-console/src/helper/helper.rs162
-rw-r--r--crates/shirabe-symfony-console/src/helper/helper_interface.rs15
-rw-r--r--crates/shirabe-symfony-console/src/helper/helper_set.rs76
-rw-r--r--crates/shirabe-symfony-console/src/helper/process_helper.rs310
-rw-r--r--crates/shirabe-symfony-console/src/helper/progress_bar.rs835
-rw-r--r--crates/shirabe-symfony-console/src/helper/question_helper.rs916
-rw-r--r--crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs163
-rw-r--r--crates/shirabe-symfony-console/src/helper/table.rs1443
-rw-r--r--crates/shirabe-symfony-console/src/helper/table_cell.rs99
-rw-r--r--crates/shirabe-symfony-console/src/helper/table_cell_style.rs114
-rw-r--r--crates/shirabe-symfony-console/src/helper/table_rows.rs45
-rw-r--r--crates/shirabe-symfony-console/src/helper/table_separator.rs46
-rw-r--r--crates/shirabe-symfony-console/src/helper/table_style.rs289
16 files changed, 4906 insertions, 0 deletions
diff --git a/crates/shirabe-symfony-console/src/helper/debug_formatter_helper.rs b/crates/shirabe-symfony-console/src/helper/debug_formatter_helper.rs
new file mode 100644
index 00000000..f7fd2157
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/debug_formatter_helper.rs
@@ -0,0 +1,175 @@
+//! ref: composer/vendor/symfony/console/Helper/DebugFormatterHelper.php
+
+use crate::helper::helper::Helper;
+use crate::helper::helper_interface::HelperInterface;
+use crate::helper::helper_set::HelperSet;
+use indexmap::IndexMap;
+
+const COLORS: [&str; 9] = [
+ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "default",
+];
+
+/// Helps outputting debug information when running an external program from a command.
+///
+/// An external program can be a Process, an HTTP request, or anything else.
+#[derive(Debug)]
+pub struct DebugFormatterHelper {
+ inner: Helper,
+ started: IndexMap<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,
+ },
+ );
+
+ format!(
+ "{}<bg=blue;fg=white> {} </> <fg=blue>{}</>\n",
+ self.get_border(id),
+ prefix,
+ message,
+ )
+ }
+
+ /// Adds progress to a formatting session.
+ pub fn progress(
+ &mut self,
+ id: &str,
+ buffer: &str,
+ error: bool,
+ prefix: &str,
+ error_prefix: &str,
+ ) -> String {
+ let mut message = String::new();
+
+ if error {
+ if self.started[id].out {
+ message.push('\n');
+ self.started.get_mut(id).unwrap().out = false;
+ }
+ if !self.started[id].err {
+ message.push_str(&format!(
+ "{}<bg=red;fg=white> {} </> ",
+ self.get_border(id),
+ error_prefix,
+ ));
+ self.started.get_mut(id).unwrap().err = true;
+ }
+
+ message.push_str(&shirabe_php_shim::str_replace(
+ "\n",
+ &format!(
+ "\n{}<bg=red;fg=white> {} </> ",
+ self.get_border(id),
+ error_prefix,
+ ),
+ buffer,
+ ));
+ } else {
+ if self.started[id].err {
+ message.push('\n');
+ self.started.get_mut(id).unwrap().err = false;
+ }
+ if !self.started[id].out {
+ message.push_str(&format!(
+ "{}<bg=green;fg=white> {} </> ",
+ self.get_border(id),
+ prefix,
+ ));
+ self.started.get_mut(id).unwrap().out = true;
+ }
+
+ message.push_str(&shirabe_php_shim::str_replace(
+ "\n",
+ &format!(
+ "\n{}<bg=green;fg=white> {} </> ",
+ self.get_border(id),
+ prefix,
+ ),
+ buffer,
+ ));
+ }
+
+ message
+ }
+
+ /// Stops a formatting session.
+ pub fn stop(&mut self, id: &str, message: &str, successful: bool, prefix: &str) -> String {
+ let trailing_eol = if self.started[id].out || self.started[id].err {
+ "\n"
+ } else {
+ ""
+ };
+
+ if successful {
+ return format!(
+ "{}{}<bg=green;fg=white> {} </> <fg=green>{}</>\n",
+ trailing_eol,
+ self.get_border(id),
+ prefix,
+ message,
+ );
+ }
+
+ let message = format!(
+ "{}{}<bg=red;fg=white> {} </> <fg=red>{}</>\n",
+ trailing_eol,
+ self.get_border(id),
+ prefix,
+ message,
+ );
+
+ if let Some(session) = self.started.get_mut(id) {
+ session.out = false;
+ session.err = false;
+ }
+
+ message
+ }
+
+ fn get_border(&self, id: &str) -> String {
+ format!("<bg={}> </>", COLORS[self.started[id].border as usize])
+ }
+}
+
+impl HelperInterface for DebugFormatterHelper {
+ fn set_helper_set(&mut self, helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>) {
+ self.inner.set_helper_set(helper_set);
+ }
+
+ fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::RefCell<HelperSet>>> {
+ self.inner.get_helper_set()
+ }
+
+ fn get_name(&self) -> String {
+ "debug_formatter".to_string()
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/descriptor_helper.rs b/crates/shirabe-symfony-console/src/helper/descriptor_helper.rs
new file mode 100644
index 00000000..23bb4f66
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/descriptor_helper.rs
@@ -0,0 +1,119 @@
+//! ref: composer/vendor/symfony/console/Helper/DescriptorHelper.php
+
+use crate::descriptor::descriptor_interface::{DescribableObject, DescriptorInterface};
+use crate::descriptor::json_descriptor::JsonDescriptor;
+use crate::descriptor::markdown_descriptor::MarkdownDescriptor;
+use crate::descriptor::text_descriptor::TextDescriptor;
+use crate::descriptor::xml_descriptor::XmlDescriptor;
+use crate::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::helper::helper::Helper;
+use crate::helper::helper_interface::HelperInterface;
+use crate::helper::helper_set::HelperSet;
+use crate::output::output_interface::OutputInterface;
+use indexmap::IndexMap;
+
+/// This class adds helper method to describe objects in various formats.
+#[derive(Default)]
+pub struct DescriptorHelper {
+ inner: Helper,
+ /// @var DescriptorInterface[]
+ descriptors: IndexMap<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(),
+ };
+ this.register("txt", Box::new(TextDescriptor::default()))
+ .register("xml", Box::new(XmlDescriptor::default()))
+ .register("json", Box::new(JsonDescriptor::default()))
+ .register("md", Box::new(MarkdownDescriptor::default()));
+ this
+ }
+
+ /// Describes an object if supported.
+ ///
+ /// Available options are:
+ /// * format: string, the output format name
+ /// * raw_text: boolean, sets output type as raw
+ ///
+ /// @throws InvalidArgumentException when the given format is not supported
+ pub fn describe2(
+ &mut self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ object: DescribableObject,
+ options: IndexMap<String, shirabe_php_shim::PhpMixed>,
+ ) -> anyhow::Result<()> {
+ 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::new(format!(
+ "Unsupported format \"{}\".",
+ format.clone()
+ ))
+ .into());
+ }
+
+ let descriptor = self.descriptors.get_mut(&format).unwrap();
+ descriptor.describe(output, object, options)
+ }
+
+ /// Registers a descriptor.
+ pub fn register(
+ &mut self,
+ format: &str,
+ descriptor: Box<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<std::rc::Rc<std::cell::RefCell<HelperSet>>>) {
+ self.inner.set_helper_set(helper_set);
+ }
+
+ fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::RefCell<HelperSet>>> {
+ self.inner.get_helper_set()
+ }
+
+ fn get_name(&self) -> String {
+ "descriptor".to_string()
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/formatter_helper.rs b/crates/shirabe-symfony-console/src/helper/formatter_helper.rs
new file mode 100644
index 00000000..f4e1b643
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/formatter_helper.rs
@@ -0,0 +1,99 @@
+//! ref: composer/vendor/symfony/console/Helper/FormatterHelper.php
+
+use crate::formatter::output_formatter::OutputFormatter;
+use crate::helper::helper::Helper;
+use crate::helper::helper_interface::HelperInterface;
+use crate::helper::helper_set::HelperSet;
+
+/// The Formatter class provides helpers to format messages.
+#[derive(Debug, Default)]
+pub struct FormatterHelper {
+ inner: Helper,
+}
+
+impl FormatterHelper {
+ /// Formats a message within a section.
+ pub fn format_section(&self, section: &str, message: &str, style: &str) -> String {
+ format!("<{}>[{}]</{}> {}", style, section, style, message)
+ }
+
+ /// Formats a message as a block of text.
+ ///
+ /// @param string|array $messages The message to write in the block
+ pub fn format_block(&self, messages: FormatBlockMessages, style: &str, large: bool) -> String {
+ let messages = match messages {
+ FormatBlockMessages::String(message) => vec![message],
+ FormatBlockMessages::Array(messages) => messages,
+ };
+
+ let mut len: i64 = 0;
+ let mut lines: Vec<String> = Vec::new();
+ for message in &messages {
+ let message = OutputFormatter::escape(message).unwrap();
+ lines.push(if large {
+ format!(" {} ", message)
+ } else {
+ format!(" {} ", message)
+ });
+ len = std::cmp::max(Helper::width(&message) + (if large { 4 } else { 2 }), len);
+ }
+
+ let mut messages: Vec<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] = format!("<{}>{}</{}>", style, messages[i].clone(), style);
+ i += 1;
+ }
+
+ messages.join("\n")
+ }
+
+ /// Truncates a message to the given length.
+ pub fn truncate(&self, message: &str, length: i64, suffix: &str) -> String {
+ let computed_length = length - Helper::width(suffix);
+
+ if computed_length > Helper::width(message) {
+ return message.to_string();
+ }
+
+ format!("{}{}", Helper::substr(message, 0, Some(length)), suffix)
+ }
+}
+
+impl HelperInterface for FormatterHelper {
+ fn set_helper_set(&mut self, helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>) {
+ self.inner.set_helper_set(helper_set);
+ }
+
+ fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::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-symfony-console/src/helper/helper.rs b/crates/shirabe-symfony-console/src/helper/helper.rs
new file mode 100644
index 00000000..0af2830f
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/helper.rs
@@ -0,0 +1,162 @@
+//! ref: composer/vendor/symfony/console/Helper/Helper.php
+
+use crate::formatter::output_formatter_interface::OutputFormatterInterface;
+use crate::helper::helper_set::HelperSet;
+use shirabe_php_shim::php_regex;
+use shirabe_symfony_string::unicode_string::UnicodeString;
+
+/// Helper is the base class for all helper classes.
+#[derive(Debug, Default)]
+pub struct Helper {
+ pub(crate) helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>,
+}
+
+impl Helper {
+ pub fn set_helper_set(
+ &mut self,
+ helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>,
+ ) {
+ self.helper_set = helper_set;
+ }
+
+ pub fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::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(php_regex!("//u"), string, &mut Vec::new()) {
+ return UnicodeString::new(string).width(false);
+ }
+
+ let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true);
+ let encoding = match encoding {
+ Some(encoding) => encoding,
+ None => return shirabe_php_shim::strlen(string),
+ };
+
+ shirabe_php_shim::mb_strwidth(string, Some(&encoding))
+ }
+
+ /// Returns the length of a string, using mb_strlen if it is available.
+ /// The length is related to how many bytes the string will use.
+ pub fn length(string: &str) -> i64 {
+ if shirabe_php_shim::preg_match(php_regex!("//u"), string, &mut Vec::new()) {
+ return UnicodeString::new(string).length();
+ }
+
+ let encoding = shirabe_php_shim::mb_detect_encoding(string, None, true);
+ let encoding = match encoding {
+ Some(encoding) => encoding,
+ None => return shirabe_php_shim::strlen(string),
+ };
+
+ shirabe_php_shim::mb_strlen(string, &encoding)
+ }
+
+ /// Returns the subset of a string, using mb_substr if it is available.
+ pub fn substr(string: &str, from: i64, length: Option<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
+ && ((index + 1 < time_formats.len() && secs < time_formats[index + 1].0)
+ || index == time_formats.len() - 1)
+ {
+ match format.2 {
+ None => return Some(format.1.to_string()),
+ Some(divisor) => {
+ return Some(format!("{} {}", (secs / divisor).floor(), format.1));
+ }
+ }
+ }
+ }
+
+ None
+ }
+
+ pub fn format_memory(memory: i64) -> String {
+ if memory >= 1024 * 1024 * 1024 {
+ return format!("{:.1} GiB", memory as f64 / 1024.0 / 1024.0 / 1024.0);
+ }
+
+ if memory >= 1024 * 1024 {
+ return format!("{:.1} MiB", memory as f64 / 1024.0 / 1024.0);
+ }
+
+ if memory >= 1024 {
+ return format!("{} KiB", memory / 1024);
+ }
+
+ format!("{} B", memory)
+ }
+
+ /// @deprecated since Symfony 5.3
+ pub fn strlen_without_decoration(
+ formatter: &mut dyn OutputFormatterInterface,
+ string: &str,
+ ) -> i64 {
+ shirabe_php_shim::trigger_deprecation(
+ "symfony/console",
+ "5.3",
+ "Method \"%s()\" is deprecated and will be removed in Symfony 6.0. Use Helper::removeDecoration() instead.",
+ "Helper::strlenWithoutDecoration",
+ );
+
+ Self::width(&Self::remove_decoration(formatter, string))
+ }
+
+ pub fn remove_decoration(formatter: &mut dyn OutputFormatterInterface, string: &str) -> String {
+ let is_decorated = formatter.is_decorated();
+ formatter.set_decorated(false);
+ // remove <...> formatting
+ let string = formatter.format(Some(string)).unwrap().unwrap_or_default();
+ // remove already formatted characters
+ let string = shirabe_php_shim::preg_replace(php_regex!("/\u{1b}\\[[^m]*m/"), "", &string);
+ // remove terminal hyperlinks
+ let string = shirabe_php_shim::preg_replace(
+ php_regex!("/\u{1b}]8;[^;]*;[^\u{1b}]*\u{1b}\\\\/"),
+ "",
+ &string,
+ );
+ formatter.set_decorated(is_decorated);
+
+ string
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/helper_interface.rs b/crates/shirabe-symfony-console/src/helper/helper_interface.rs
new file mode 100644
index 00000000..41c0b680
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/helper_interface.rs
@@ -0,0 +1,15 @@
+//! ref: composer/vendor/symfony/console/Helper/HelperInterface.php
+
+use crate::helper::helper_set::HelperSet;
+
+/// HelperInterface is the interface all helpers must implement.
+pub trait HelperInterface: std::fmt::Debug + shirabe_php_shim::AsAny {
+ /// Sets the helper set associated with this helper.
+ fn set_helper_set(&mut self, helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>);
+
+ /// Gets the helper set associated with this helper.
+ fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>;
+
+ /// Returns the canonical name of this helper.
+ fn get_name(&self) -> String;
+}
diff --git a/crates/shirabe-symfony-console/src/helper/helper_set.rs b/crates/shirabe-symfony-console/src/helper/helper_set.rs
new file mode 100644
index 00000000..bf64f8b2
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/helper_set.rs
@@ -0,0 +1,76 @@
+//! ref: composer/vendor/symfony/console/Helper/HelperSet.php
+
+use crate::helper::debug_formatter_helper::DebugFormatterHelper;
+use crate::helper::formatter_helper::FormatterHelper;
+use crate::helper::helper_interface::HelperInterface;
+use crate::helper::process_helper::ProcessHelper;
+use crate::helper::question_helper::QuestionHelper;
+
+/// HelperSet represents a set of helpers to be used with a command.
+///
+/// Symfony lets arbitrary helpers be registered by name, but Composer only ever uses the four
+/// helpers `Application::getDefaultHelperSet()` installs. This port closes the set to exactly those
+/// four, instantiates them in the argument-less constructor, and exposes them through typed getters
+/// instead of Symfony's string-keyed `get()`/`has()`/`set()`.
+///
+/// TODO(plugin): a plugin-defined custom command may register extra helpers dynamically via
+/// `getApplication()->getHelperSet()`. Restoring that path (a `set()` equivalent plus name-based
+/// lookup) is deferred until the plugin API is implemented.
+#[derive(Debug)]
+pub struct HelperSet {
+ formatter_helper: std::rc::Rc<std::cell::RefCell<FormatterHelper>>,
+ debug_formatter_helper: std::rc::Rc<std::cell::RefCell<DebugFormatterHelper>>,
+ process_helper: std::rc::Rc<std::cell::RefCell<ProcessHelper>>,
+ question_helper: std::rc::Rc<std::cell::RefCell<QuestionHelper>>,
+}
+
+impl HelperSet {
+ /// Builds the fixed set of helpers and wires each one's back-reference to the owning set,
+ /// mirroring the `$helper->setHelperSet($this)` call PHP's `HelperSet::set()` performs.
+ pub fn new() -> std::rc::Rc<std::cell::RefCell<HelperSet>> {
+ let formatter_helper =
+ std::rc::Rc::new(std::cell::RefCell::new(FormatterHelper::default()));
+ let debug_formatter_helper =
+ std::rc::Rc::new(std::cell::RefCell::new(DebugFormatterHelper::default()));
+ let process_helper = std::rc::Rc::new(std::cell::RefCell::new(ProcessHelper::default()));
+ let question_helper = std::rc::Rc::new(std::cell::RefCell::new(QuestionHelper::default()));
+
+ let this = std::rc::Rc::new(std::cell::RefCell::new(HelperSet {
+ formatter_helper: formatter_helper.clone(),
+ debug_formatter_helper: debug_formatter_helper.clone(),
+ process_helper: process_helper.clone(),
+ question_helper: question_helper.clone(),
+ }));
+
+ formatter_helper
+ .borrow_mut()
+ .set_helper_set(Some(this.clone()));
+ debug_formatter_helper
+ .borrow_mut()
+ .set_helper_set(Some(this.clone()));
+ process_helper
+ .borrow_mut()
+ .set_helper_set(Some(this.clone()));
+ question_helper
+ .borrow_mut()
+ .set_helper_set(Some(this.clone()));
+
+ this
+ }
+
+ pub fn get_formatter(&self) -> std::rc::Rc<std::cell::RefCell<FormatterHelper>> {
+ self.formatter_helper.clone()
+ }
+
+ pub fn get_debug_formatter(&self) -> std::rc::Rc<std::cell::RefCell<DebugFormatterHelper>> {
+ self.debug_formatter_helper.clone()
+ }
+
+ pub fn get_process(&self) -> std::rc::Rc<std::cell::RefCell<ProcessHelper>> {
+ self.process_helper.clone()
+ }
+
+ pub fn get_question(&self) -> std::rc::Rc<std::cell::RefCell<QuestionHelper>> {
+ self.question_helper.clone()
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/process_helper.rs b/crates/shirabe-symfony-console/src/helper/process_helper.rs
new file mode 100644
index 00000000..be1c1a84
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/process_helper.rs
@@ -0,0 +1,310 @@
+//! ref: composer/vendor/symfony/console/Helper/ProcessHelper.php
+
+use crate::helper::debug_formatter_helper::DebugFormatterHelper;
+use crate::helper::helper::Helper;
+use crate::helper::helper_interface::HelperInterface;
+use crate::helper::helper_set::HelperSet;
+use crate::output::ConsoleOutputInterface;
+use crate::output::output_interface::{self, OutputInterface};
+use shirabe_symfony_process::exception::process_failed_exception::ProcessFailedException;
+use shirabe_symfony_process::process::Process;
+
+/// The ProcessHelper class provides helpers to run external processes.
+///
+/// @final
+#[derive(Debug, Default)]
+pub struct ProcessHelper {
+ inner: Helper,
+}
+
+/// `$cmd` is either a `Process` instance or an array whose first element is a
+/// binary path (string) or a `Process`, followed by extra environment entries.
+#[derive(Debug)]
+pub enum ProcessHelperCmd {
+ Process(Process),
+ Array(Vec<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: std::rc::Rc<std::cell::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(); }`. ConsoleOutput is the only OutputInterface
+ // implementor that also implements ConsoleOutputInterface, so the check
+ // reduces to a downcast to the concrete type.
+ let output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> = {
+ let redirected = shirabe_php_shim::AsAny::as_any(&*output.borrow())
+ .downcast_ref::<crate::output::console_output::ConsoleOutput>()
+ .map(|console| console.get_error_output());
+ redirected.unwrap_or(output)
+ };
+
+ let formatter: std::rc::Rc<std::cell::RefCell<DebugFormatterHelper>> = self
+ .get_helper_set()
+ .unwrap()
+ .borrow()
+ .get_debug_formatter();
+
+ // Normalize $cmd: a single Process becomes a one-element array.
+ let mut cmd = match cmd {
+ ProcessHelperCmd::Process(process) => {
+ vec![ProcessHelperCmdElement::Process(process)]
+ }
+ ProcessHelperCmd::Array(cmd) => cmd,
+ };
+
+ // `!\is_array($cmd)` cannot happen given the enum, so the TypeError branch
+ // is unreachable here.
+
+ let mut process: Process;
+ match cmd.first() {
+ Some(ProcessHelperCmdElement::String(_)) => {
+ let command: Vec<String> = cmd
+ .iter()
+ .map(|element| match element {
+ ProcessHelperCmdElement::String(s) => s.clone(),
+ ProcessHelperCmdElement::Process(_) => unreachable!(),
+ })
+ .collect();
+ process = Process::new(
+ command,
+ None,
+ None,
+ shirabe_php_shim::PhpMixed::Null,
+ Some(60.0),
+ )?;
+ cmd = vec![];
+ }
+ Some(ProcessHelperCmdElement::Process(_)) => {
+ let first = cmd.remove(0);
+ process = match first {
+ ProcessHelperCmdElement::Process(process) => process,
+ ProcessHelperCmdElement::String(_) => unreachable!(),
+ };
+ }
+ None => {
+ anyhow::bail!(shirabe_php_shim::InvalidArgumentException::new(format!(
+ "Invalid command provided to \"{}()\": the command should be an array whose first element is either the path to the binary to run or a \"Process\" object.",
+ shirabe_php_shim::PhpMixed::String("ProcessHelper::run".to_string()),
+ )));
+ }
+ }
+
+ if verbosity <= output.borrow().get_verbosity() {
+ let started = Self::formatter_start(
+ &formatter,
+ &shirabe_php_shim::spl_object_hash(&process),
+ &self.escape_string(&process.get_command_line()),
+ );
+ output
+ .borrow()
+ .write(&[started], false, output_interface::OUTPUT_NORMAL);
+ }
+
+ let callback = if output.borrow().is_debug() {
+ Some(self.wrap_callback(output.clone(), &process, callback))
+ } else {
+ callback
+ };
+
+ // PHP passes the remaining `$cmd` array as the `$env` argument to Process::run.
+ let env: indexmap::IndexMap<String, shirabe_php_shim::PhpMixed> = cmd
+ .iter()
+ .enumerate()
+ .filter_map(|(i, element)| match element {
+ ProcessHelperCmdElement::String(s) => {
+ Some((i.to_string(), shirabe_php_shim::PhpMixed::String(s.clone())))
+ }
+ ProcessHelperCmdElement::Process(_) => None,
+ })
+ .collect();
+ let callback: Option<Box<dyn FnMut(&str, &str) -> bool>> = callback.map(|mut cb| {
+ Box::new(move |r#type: &str, buffer: &str| -> bool {
+ cb(r#type, buffer);
+ false
+ }) as Box<dyn FnMut(&str, &str) -> bool>
+ });
+ process.run(callback, env)?;
+
+ if verbosity <= output.borrow().get_verbosity() {
+ let message = if process.is_successful() {
+ "Command ran successfully".to_string()
+ } else {
+ format!(
+ "{} Command did not run successfully",
+ match process.get_exit_code() {
+ Some(code) => shirabe_php_shim::PhpMixed::Int(code),
+ None => shirabe_php_shim::PhpMixed::Null,
+ },
+ )
+ };
+ let stopped = Self::formatter_stop(
+ &formatter,
+ &shirabe_php_shim::spl_object_hash(&process),
+ &message,
+ process.is_successful(),
+ );
+ output
+ .borrow()
+ .write(&[stopped], false, output_interface::OUTPUT_NORMAL);
+ }
+
+ if !process.is_successful()
+ && let Some(error) = error
+ {
+ output.borrow().writeln(
+ &[format!("<error>{}</error>", self.escape_string(error))],
+ output_interface::OUTPUT_NORMAL,
+ );
+ }
+
+ Ok(process)
+ }
+
+ /// Runs the process.
+ ///
+ /// This is identical to run() except that an exception is thrown if the process
+ /// exits with a non-zero exit code.
+ ///
+ /// @param array|Process $cmd An instance of Process or a command to run
+ /// @param callable|null $callback A PHP callback to run whenever there is some
+ /// output available on STDOUT or STDERR
+ ///
+ /// @throws ProcessFailedException
+ ///
+ /// @see run()
+ pub fn must_run(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ cmd: ProcessHelperCmd,
+ error: Option<&str>,
+ callback: Option<Box<dyn FnMut(&str, &str)>>,
+ ) -> anyhow::Result<Process> {
+ let mut process = self.run(
+ output,
+ cmd,
+ error,
+ callback,
+ output_interface::VERBOSITY_VERY_VERBOSE,
+ )?;
+
+ if !process.is_successful() {
+ anyhow::bail!(ProcessFailedException::new(&mut process)?);
+ }
+
+ Ok(process)
+ }
+
+ /// Wraps a Process callback to add debugging output.
+ pub fn wrap_callback(
+ &self,
+ output: std::rc::Rc<std::cell::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(); }`. ConsoleOutput is the only OutputInterface
+ // implementor that also implements ConsoleOutputInterface, so the check
+ // reduces to a downcast to the concrete type.
+ let output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> = {
+ let redirected = shirabe_php_shim::AsAny::as_any(&*output.borrow())
+ .downcast_ref::<crate::output::console_output::ConsoleOutput>()
+ .map(|console| console.get_error_output());
+ redirected.unwrap_or(output)
+ };
+
+ let formatter: std::rc::Rc<std::cell::RefCell<DebugFormatterHelper>> = self
+ .get_helper_set()
+ .unwrap()
+ .borrow()
+ .get_debug_formatter();
+
+ let object_hash = shirabe_php_shim::spl_object_hash(process);
+
+ Box::new(move |r#type: &str, buffer: &str| {
+ let progressed = Self::formatter_progress(
+ &formatter,
+ &object_hash,
+ &Self::escape_string_static(buffer),
+ Process::ERR == r#type,
+ );
+ output
+ .borrow()
+ .write(&[progressed], false, output_interface::OUTPUT_NORMAL);
+
+ if let Some(callback) = callback.as_mut() {
+ callback(r#type, buffer);
+ }
+ })
+ }
+
+ fn escape_string(&self, str: &str) -> String {
+ shirabe_php_shim::str_replace("<", "\\<", str)
+ }
+
+ fn escape_string_static(str: &str) -> String {
+ shirabe_php_shim::str_replace("<", "\\<", str)
+ }
+
+ fn formatter_start(
+ formatter: &std::rc::Rc<std::cell::RefCell<DebugFormatterHelper>>,
+ id: &str,
+ message: &str,
+ ) -> String {
+ formatter.borrow_mut().start(id, message, "RUN")
+ }
+
+ fn formatter_stop(
+ formatter: &std::rc::Rc<std::cell::RefCell<DebugFormatterHelper>>,
+ id: &str,
+ message: &str,
+ successful: bool,
+ ) -> String {
+ formatter.borrow_mut().stop(id, message, successful, "RES")
+ }
+
+ fn formatter_progress(
+ formatter: &std::rc::Rc<std::cell::RefCell<DebugFormatterHelper>>,
+ id: &str,
+ buffer: &str,
+ error: bool,
+ ) -> String {
+ formatter
+ .borrow_mut()
+ .progress(id, buffer, error, "OUT", "ERR")
+ }
+}
+
+impl HelperInterface for ProcessHelper {
+ fn set_helper_set(&mut self, helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>) {
+ self.inner.set_helper_set(helper_set);
+ }
+
+ fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::RefCell<HelperSet>>> {
+ self.inner.get_helper_set()
+ }
+
+ fn get_name(&self) -> String {
+ "process".to_string()
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/progress_bar.rs b/crates/shirabe-symfony-console/src/helper/progress_bar.rs
new file mode 100644
index 00000000..44e144cf
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/progress_bar.rs
@@ -0,0 +1,835 @@
+//! ref: composer/vendor/symfony/console/Helper/ProgressBar.php
+
+use crate::cursor::Cursor;
+use crate::exception::logic_exception::LogicException;
+use crate::helper::helper::Helper;
+use crate::output::ConsoleOutputInterface;
+use crate::output::ConsoleSectionOutput;
+use crate::output::OutputInterface;
+use crate::output::output_interface;
+use crate::terminal::Terminal;
+use indexmap::IndexMap;
+
+pub const FORMAT_VERBOSE: &str = "verbose";
+pub const FORMAT_VERY_VERBOSE: &str = "very_verbose";
+pub const FORMAT_DEBUG: &str = "debug";
+pub const FORMAT_NORMAL: &str = "normal";
+
+const FORMAT_VERBOSE_NOMAX: &str = "verbose_nomax";
+const FORMAT_VERY_VERBOSE_NOMAX: &str = "very_verbose_nomax";
+const FORMAT_DEBUG_NOMAX: &str = "debug_nomax";
+const FORMAT_NORMAL_NOMAX: &str = "normal_nomax";
+
+/// A placeholder formatter callable, receiving the bar and the output.
+pub type PlaceholderFormatter = Box<
+ dyn Fn(
+ &ProgressBar,
+ &std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ ) -> anyhow::Result<Result<shirabe_php_shim::PhpMixed, LogicException>>,
+>;
+
+/// The ProgressBar provides helpers to display progress output.
+#[derive(Debug)]
+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: std::rc::Rc<std::cell::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: std::cell::RefCell<Option<IndexMap<String, PlaceholderFormatter>>> = const { std::cell::RefCell::new(None) };
+ static FORMATS: std::cell::RefCell<Option<IndexMap<String, String>>> = const { std::cell::RefCell::new(None) };
+}
+
+impl ProgressBar {
+ /// `$max` Maximum steps (0 if unknown)
+ pub fn new(
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ max: i64,
+ min_seconds_between_redraws: f64,
+ ) -> Self {
+ // PHP: `if ($output instanceof ConsoleOutputInterface) { $output =
+ // $output->getErrorOutput(); }`. ConsoleOutput is the only OutputInterface
+ // implementor that also implements ConsoleOutputInterface, so the check
+ // reduces to a downcast to the concrete type.
+ let output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>> = {
+ let redirected = shirabe_php_shim::AsAny::as_any(&*output.borrow())
+ .downcast_ref::<crate::output::console_output::ConsoleOutput>()
+ .map(|console| console.get_error_output());
+ redirected.unwrap_or(output)
+ };
+
+ let mut this = Self {
+ bar_width: 28,
+ bar_char: None,
+ empty_bar_char: "-".to_string(),
+ progress_char: ">".to_string(),
+ format: None,
+ internal_format: None,
+ redraw_freq: Some(1),
+ write_count: 0,
+ last_write_time: 0.0,
+ min_seconds_between_redraws: 0.0,
+ max_seconds_between_redraws: 1.0,
+ output: output.clone(),
+ step: 0,
+ max: 0,
+ start_time: 0,
+ step_width: 0,
+ percent: 0.0,
+ messages: IndexMap::new(),
+ overwrite: true,
+ terminal: Terminal::new(),
+ previous_message: None,
+ cursor: Cursor::new(output.clone(), None),
+ };
+
+ this.set_max_steps(max);
+
+ if 0.0 < min_seconds_between_redraws {
+ this.redraw_freq = None;
+ this.min_seconds_between_redraws = min_seconds_between_redraws;
+ }
+
+ if !this.output.borrow().is_decorated() {
+ // disable overwrite when output does not support ANSI codes.
+ this.overwrite = false;
+
+ // set a reasonable redraw frequency so output isn't flooded
+ this.redraw_freq = None;
+ }
+
+ this.start_time = shirabe_php_shim::time();
+
+ this
+ }
+
+ /// Sets a placeholder formatter for a given name.
+ ///
+ /// This method also allow you to override an existing placeholder.
+ ///
+ /// `$name` The placeholder name (including the delimiter char like %)
+ /// `$callable` A PHP callable
+ pub fn set_placeholder_formatter_definition(name: &str, callable: PlaceholderFormatter) {
+ FORMATTERS.with(|formatters| {
+ let mut formatters = formatters.borrow_mut();
+ if formatters.is_none() {
+ *formatters = Some(Self::init_placeholder_formatters());
+ }
+
+ formatters
+ .as_mut()
+ .unwrap()
+ .insert(name.to_string(), callable);
+ });
+ }
+
+ /// Gets the placeholder formatter for a given name.
+ ///
+ /// `$name` The placeholder name (including the delimiter char like %)
+ pub fn get_placeholder_formatter_definition(name: &str) -> Option<()> {
+ // Note: the returned callable cannot be cloned out of the thread-local
+ // map; call sites invoke the formatter via the map directly.
+ FORMATTERS.with(|formatters| {
+ let mut formatters = formatters.borrow_mut();
+ if formatters.is_none() {
+ *formatters = Some(Self::init_placeholder_formatters());
+ }
+
+ formatters.as_ref().unwrap().get(name).map(|_| ())
+ })
+ }
+
+ /// Sets a format for a given name.
+ ///
+ /// This method also allow you to override an existing format.
+ ///
+ /// `$name` The format name
+ /// `$format` A format string
+ pub fn set_format_definition(name: &str, format: &str) {
+ FORMATS.with(|formats| {
+ let mut formats = formats.borrow_mut();
+ if formats.is_none() {
+ *formats = Some(Self::init_formats());
+ }
+
+ formats
+ .as_mut()
+ .unwrap()
+ .insert(name.to_string(), format.to_string());
+ });
+ }
+
+ /// Gets the format for a given name.
+ ///
+ /// `$name` The format name
+ pub fn get_format_definition(name: &str) -> Option<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()
+ })
+ }
+
+ /// 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 {
+ self.max
+ }
+
+ pub fn get_progress(&self) -> i64 {
+ self.step
+ }
+
+ fn get_step_width(&self) -> i64 {
+ self.step_width
+ }
+
+ pub fn get_progress_percent(&self) -> f64 {
+ self.percent
+ }
+
+ pub fn get_bar_offset(&self) -> f64 {
+ f64::floor(if self.max != 0 {
+ self.percent * self.bar_width as f64
+ } else if self.redraw_freq.is_none() {
+ (((self.bar_width / 15).min(5) * self.write_count) % self.bar_width) as f64
+ } else {
+ (self.step % self.bar_width) as f64
+ })
+ }
+
+ pub fn get_estimated(&self) -> f64 {
+ if self.step == 0 {
+ return 0.0;
+ }
+
+ shirabe_php_shim::round(
+ (shirabe_php_shim::time() - self.start_time) as f64 / self.step as f64
+ * self.max as f64,
+ 0,
+ )
+ }
+
+ pub fn get_remaining(&self) -> f64 {
+ if self.step == 0 {
+ return 0.0;
+ }
+
+ shirabe_php_shim::round(
+ (shirabe_php_shim::time() - self.start_time) as f64 / self.step as f64
+ * (self.max - self.step) as f64,
+ 0,
+ )
+ }
+
+ pub fn set_bar_width(&mut self, size: i64) {
+ self.bar_width = size.max(1);
+ }
+
+ pub fn get_bar_width(&self) -> i64 {
+ self.bar_width
+ }
+
+ pub fn set_bar_character(&mut self, char: &str) {
+ self.bar_char = Some(char.to_string());
+ }
+
+ pub fn get_bar_character(&self) -> String {
+ match &self.bar_char {
+ Some(bar_char) => bar_char.clone(),
+ None => {
+ if self.max != 0 {
+ "=".to_string()
+ } else {
+ self.empty_bar_char.clone()
+ }
+ }
+ }
+ }
+
+ pub fn set_empty_bar_character(&mut self, char: &str) {
+ self.empty_bar_char = char.to_string();
+ }
+
+ pub fn get_empty_bar_character(&self) -> String {
+ self.empty_bar_char.clone()
+ }
+
+ pub fn set_progress_character(&mut self, char: &str) {
+ self.progress_char = char.to_string();
+ }
+
+ pub fn get_progress_character(&self) -> String {
+ self.progress_char.clone()
+ }
+
+ pub fn set_format(&mut self, format: &str) {
+ self.format = None;
+ self.internal_format = Some(format.to_string());
+ }
+
+ /// Sets the redraw frequency.
+ ///
+ /// `$freq` The frequency in steps
+ pub fn set_redraw_frequency(&mut self, freq: Option<i64>) {
+ self.redraw_freq = freq.map(|freq| freq.max(1));
+ }
+
+ pub fn min_seconds_between_redraws(&mut self, seconds: f64) {
+ self.min_seconds_between_redraws = seconds;
+ }
+
+ pub fn max_seconds_between_redraws(&mut self, seconds: f64) {
+ self.max_seconds_between_redraws = seconds;
+ }
+
+ /// Returns an iterator that will automatically update the progress bar when iterated.
+ ///
+ /// `$max` Number of steps to complete the bar (0 if indeterminate), if null it will be
+ /// inferred from `$iterable`
+ pub fn iterate(
+ &mut self,
+ iterable: Vec<(shirabe_php_shim::PhpMixed, shirabe_php_shim::PhpMixed)>,
+ max: Option<i64>,
+ ) -> anyhow::Result<Vec<(shirabe_php_shim::PhpMixed, shirabe_php_shim::PhpMixed)>> {
+ self.start(Some(max.unwrap_or({
+ // is_countable($iterable) ? \count($iterable) : 0
+ iterable.len() as i64
+ })))?;
+
+ let mut yielded = Vec::new();
+ for (key, value) in iterable {
+ yielded.push((key, value));
+
+ self.advance(1)?;
+ }
+
+ self.finish()?;
+
+ Ok(yielded)
+ }
+
+ /// Starts the progress output.
+ ///
+ /// `$max` Number of steps to complete the bar (0 if indeterminate), null to leave unchanged
+ pub fn start(&mut self, max: Option<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() - self.last_write_time;
+
+ // Draw regardless of other limits
+ if self.max == step {
+ self.display()?;
+
+ return Ok(());
+ }
+
+ // Throttling
+ if time_interval < self.min_seconds_between_redraws {
+ return Ok(());
+ }
+
+ // Draw each step period, but not too late
+ if prev_period != curr_period || time_interval >= self.max_seconds_between_redraws {
+ self.display()?;
+ }
+
+ Ok(())
+ }
+
+ pub fn set_max_steps(&mut self, max: i64) {
+ self.format = None;
+ self.max = max.max(0);
+ self.step_width = if self.max != 0 {
+ Helper::width(&self.max.to_string())
+ } else {
+ 4
+ };
+ }
+
+ /// Finishes the progress output.
+ pub fn finish(&mut self) -> anyhow::Result<()> {
+ if self.max == 0 {
+ self.max = self.step;
+ }
+
+ if self.step == self.max && !self.overwrite {
+ // prevent double 100% output
+ return Ok(());
+ }
+
+ self.set_progress(self.max)
+ }
+
+ /// Outputs the current progress string.
+ pub fn display(&mut self) -> anyhow::Result<()> {
+ if output_interface::VERBOSITY_QUIET == self.output.borrow().get_verbosity() {
+ return Ok(());
+ }
+
+ if self.format.is_none() {
+ let format = match &self.internal_format {
+ Some(internal_format) if !internal_format.is_empty() => internal_format.clone(),
+ _ => self.determine_best_format().to_string(),
+ };
+ self.set_real_format(&format);
+ }
+
+ let line = self.build_line()?;
+ self.overwrite(&line);
+
+ Ok(())
+ }
+
+ /// Removes the progress bar from the current line.
+ ///
+ /// This is useful if you wish to write some output
+ /// while a progress bar is running.
+ /// Call display() to show the progress bar again.
+ pub fn clear(&mut self) -> anyhow::Result<()> {
+ if !self.overwrite {
+ return Ok(());
+ }
+
+ if self.format.is_none() {
+ let format = match &self.internal_format {
+ Some(internal_format) if !internal_format.is_empty() => internal_format.clone(),
+ _ => self.determine_best_format().to_string(),
+ };
+ self.set_real_format(&format);
+ }
+
+ self.overwrite("");
+
+ Ok(())
+ }
+
+ fn set_real_format(&mut self, format: &str) {
+ // try to use the _nomax variant if available
+ if self.max == 0 && Self::get_format_definition(&format!("{format}_nomax")).is_some() {
+ self.format = Self::get_format_definition(&format!("{format}_nomax"));
+ } else if Self::get_format_definition(format).is_some() {
+ self.format = Self::get_format_definition(format);
+ } else {
+ self.format = Some(format.to_string());
+ }
+ }
+
+ /// Overwrites a previous message to the output.
+ fn overwrite(&mut self, message: &str) {
+ if self.previous_message.as_deref() == Some(message) {
+ return;
+ }
+
+ let original_message = message.to_string();
+ let mut message = message.to_string();
+
+ if self.overwrite {
+ if let Some(previous_message) = self.previous_message.clone() {
+ // PHP: `$this->output instanceof ConsoleSectionOutput`. Downcast the
+ // shared output handle to the concrete section type.
+ let output_ref = self.output.borrow();
+ if let Some(section) = shirabe_php_shim::AsAny::as_any(&*output_ref)
+ .downcast_ref::<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 formatter = section.get_formatter();
+ let message_line_length = Helper::width(&Helper::remove_decoration(
+ &mut *formatter.borrow_mut(),
+ message_line,
+ ));
+ if message_line_length > self.terminal.get_width() {
+ line_count += (message_line_length as f64
+ / self.terminal.get_width() as f64)
+ .floor() as i64;
+ }
+ }
+ section.clear(Some(line_count));
+ } else {
+ drop(output_ref);
+ let line_count = shirabe_php_shim::substr_count(&previous_message, "\n");
+ for _i in 0..line_count {
+ self.cursor.move_to_column(1);
+ self.cursor.clear_line();
+ self.cursor.move_up(1);
+ }
+
+ self.cursor.move_to_column(1);
+ self.cursor.clear_line();
+ }
+ }
+ } else if self.step > 0 {
+ message = format!("{}{}", shirabe_php_shim::PHP_EOL, message);
+ }
+
+ self.previous_message = Some(original_message);
+ self.last_write_time = shirabe_php_shim::microtime();
+
+ self.output
+ .borrow()
+ .write(&[message], false, output_interface::OUTPUT_NORMAL);
+ self.write_count += 1;
+ }
+
+ fn determine_best_format(&self) -> &'static str {
+ match self.output.borrow().get_verbosity() {
+ // OutputInterface::VERBOSITY_QUIET: display is disabled anyway
+ output_interface::VERBOSITY_VERBOSE => {
+ if self.max != 0 {
+ FORMAT_VERBOSE
+ } else {
+ FORMAT_VERBOSE_NOMAX
+ }
+ }
+ output_interface::VERBOSITY_VERY_VERBOSE => {
+ if self.max != 0 {
+ FORMAT_VERY_VERBOSE
+ } else {
+ FORMAT_VERY_VERBOSE_NOMAX
+ }
+ }
+ output_interface::VERBOSITY_DEBUG => {
+ if self.max != 0 {
+ FORMAT_DEBUG
+ } else {
+ FORMAT_DEBUG_NOMAX
+ }
+ }
+ _ => {
+ if self.max != 0 {
+ FORMAT_NORMAL
+ } else {
+ FORMAT_NORMAL_NOMAX
+ }
+ }
+ }
+ }
+
+ fn init_placeholder_formatters() -> IndexMap<String, PlaceholderFormatter> {
+ let mut formatters: IndexMap<String, PlaceholderFormatter> = IndexMap::new();
+
+ formatters.insert(
+ "bar".to_string(),
+ Box::new(
+ |bar: &ProgressBar,
+ output: &std::rc::Rc<std::cell::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(
+ &mut *output.borrow().get_formatter().borrow_mut(),
+ &bar.get_progress_character(),
+ )) as f64;
+ display.push_str(&format!(
+ "{}{}",
+ bar.get_progress_character(),
+ shirabe_php_shim::str_repeat(
+ &bar.get_empty_bar_character(),
+ empty_bars as usize
+ )
+ ));
+ }
+
+ Ok(Ok(shirabe_php_shim::PhpMixed::String(display)))
+ },
+ ),
+ );
+
+ formatters.insert(
+ "elapsed".to_string(),
+ Box::new(
+ |bar: &ProgressBar,
+ _output: &std::rc::Rc<std::cell::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: &std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>| {
+ if bar.get_max_steps() == 0 {
+ return Ok(Err(LogicException::new("Unable to display the remaining time if the maximum number of steps is not set.".to_string())));
+ }
+
+ Ok(Ok(shirabe_php_shim::PhpMixed::String(
+ Helper::format_time(bar.get_remaining()).unwrap_or_default(),
+ )))
+ }),
+ );
+
+ formatters.insert(
+ "estimated".to_string(),
+ Box::new(|bar: &ProgressBar, _output: &std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>| {
+ if bar.get_max_steps() == 0 {
+ return Ok(Err(LogicException::new("Unable to display the estimated time if the maximum number of steps is not set.".to_string())));
+ }
+
+ Ok(Ok(shirabe_php_shim::PhpMixed::String(
+ Helper::format_time(bar.get_estimated()).unwrap_or_default(),
+ )))
+ }),
+ );
+
+ formatters.insert(
+ "memory".to_string(),
+ Box::new(
+ |_bar: &ProgressBar,
+ _output: &std::rc::Rc<std::cell::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: &std::rc::Rc<std::cell::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: &std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>| {
+ Ok(Ok(shirabe_php_shim::PhpMixed::Int(bar.get_max_steps())))
+ },
+ ),
+ );
+
+ formatters.insert(
+ "percent".to_string(),
+ Box::new(
+ |bar: &ProgressBar,
+ _output: &std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>| {
+ Ok(Ok(shirabe_php_shim::PhpMixed::Float(
+ (bar.get_progress_percent() * 100.0).floor(),
+ )))
+ },
+ ),
+ );
+
+ 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(
+ &mut *self.output.borrow().get_formatter().borrow_mut(),
+ &shirabe_php_shim::rtrim(sub_line, Some("\r")),
+ ))
+ })
+ .collect();
+
+ let lines_width = *lines_length.iter().max().unwrap();
+
+ let terminal_width = self.terminal.get_width();
+ if lines_width <= terminal_width {
+ return Ok(line);
+ }
+
+ self.set_bar_width(self.bar_width - lines_width + terminal_width);
+
+ self.build_line_apply(regex)
+ }
+
+ /// Applies the placeholder-resolving callback over `$this->format`, mirroring
+ /// the `preg_replace_callback` invocation in PHP's `buildLine()`.
+ fn build_line_apply(&self, regex: &str) -> anyhow::Result<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)
+ });
+ formatter_result??
+ } else if let Some(message) = self.messages.get(&name) {
+ shirabe_php_shim::PhpMixed::String(message.clone())
+ } else {
+ return Ok(matches[0].clone().unwrap_or_default());
+ };
+
+ if let Some(modifier) = matches.get(2).and_then(|m| m.clone()) {
+ return Ok(shirabe_php_shim::sprintf(&format!("%{modifier}"), &[text]));
+ }
+
+ // PHP implicitly casts the formatter result to string here.
+ Ok(match text {
+ shirabe_php_shim::PhpMixed::String(s) => s,
+ shirabe_php_shim::PhpMixed::Int(i) => i.to_string(),
+ shirabe_php_shim::PhpMixed::Float(f) => {
+ format!("{}", f)
+ }
+ other => format!("{}", other),
+ })
+ };
+
+ shirabe_php_shim::preg_replace_callback(regex, callback, &format)
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/question_helper.rs b/crates/shirabe-symfony-console/src/helper/question_helper.rs
new file mode 100644
index 00000000..6ee83702
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/question_helper.rs
@@ -0,0 +1,916 @@
+//! ref: composer/vendor/symfony/console/Helper/QuestionHelper.php
+
+use crate::cursor::Cursor;
+use crate::exception::missing_input_exception::MissingInputException;
+use crate::exception::runtime_exception::RuntimeException;
+use crate::formatter::output_formatter::OutputFormatter;
+use crate::formatter::output_formatter_style::OutputFormatterStyle;
+use crate::helper::formatter_helper::FormatBlockMessages;
+use crate::helper::helper::Helper;
+use crate::helper::helper_interface::HelperInterface;
+use crate::helper::helper_set::HelperSet;
+use crate::input::input_interface::InputInterface;
+use crate::output::console_output::ConsoleOutput;
+use crate::output::console_output_interface::ConsoleOutputInterface;
+use crate::output::console_section_output::ConsoleSectionOutput;
+use crate::output::output_interface;
+use crate::output::output_interface::OutputInterface;
+use crate::question::ChoiceQuestion;
+use crate::question::QuestionInterface;
+use crate::terminal::Terminal;
+use shirabe_php_shim::PhpMixed;
+use shirabe_symfony_string::s;
+
+/// The QuestionHelper class provides helpers to interact with the user.
+#[derive(Debug, Default)]
+pub struct QuestionHelper {
+ pub(crate) inner: Helper,
+
+ /// @var resource|null
+ input_stream: Option<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);
+
+/// PHP dispatches `$this->writePrompt()` / `$this->writeError()` virtually, and
+/// `SymfonyQuestionHelper` overrides both protected methods. The embedded-super port expresses
+/// that late binding as a trait: the template methods are provided here and reach the overridable
+/// hooks through `Self`, while `inner()` reaches the base-class state. PHP has no such interface;
+/// the invented name follows the `QuestionInterface` precedent.
+pub trait QuestionHelperInterface {
+ fn inner(&self) -> &QuestionHelper;
+
+ fn inner_mut(&mut self) -> &mut QuestionHelper;
+
+ /// Asks a question to the user.
+ ///
+ /// @return mixed The user answer
+ ///
+ /// @throws RuntimeException If there is no data to read in the input stream
+ fn ask(
+ &mut self,
+ input: &mut dyn InputInterface,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ question: &impl QuestionInterface,
+ ) -> 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.inner().get_default_answer(question)));
+ }
+
+ if let Some(streamable) = input.as_streamable()
+ && let Some(stream) = streamable.get_stream()
+ {
+ self.inner_mut().input_stream = Some(stream);
+ }
+
+ let result: anyhow::Result<Result<PhpMixed, MissingInputException>> = (|| {
+ if question.get_validator().is_none() {
+ return self.do_ask(std::rc::Rc::clone(&output), question);
+ }
+
+ let interviewer = || self.do_ask(std::rc::Rc::clone(&output), question);
+
+ self.validate_attempts(&interviewer, std::rc::Rc::clone(&output), question)
+ })();
+
+ let result = result?;
+ match result {
+ Ok(value) => Ok(Ok(value)),
+ Err(exception) => {
+ input.set_interactive(false);
+
+ let fallback_output = self.inner().get_default_answer(question);
+ if matches!(fallback_output, PhpMixed::Null) {
+ return Ok(Err(exception));
+ }
+
+ Ok(Ok(fallback_output))
+ }
+ }
+ }
+
+ /// Asks the question to the user (PHP private; on the trait so it can late-bind the hooks).
+ ///
+ /// @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden
+ fn do_ask(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ question: &impl QuestionInterface,
+ ) -> anyhow::Result<Result<PhpMixed, MissingInputException>> {
+ self.write_prompt(std::rc::Rc::clone(&output), question);
+
+ let input_stream = self
+ .inner()
+ .input_stream
+ .clone()
+ .unwrap_or_else(shirabe_php_shim::stdin);
+ let autocomplete = question.get_autocompleter_callback();
+
+ let ret: PhpMixed;
+
+ if let Some(autocomplete) = autocomplete
+ && STTY.load(std::sync::atomic::Ordering::SeqCst)
+ && Terminal::has_stty_available()
+ {
+ let callback = autocomplete;
+ // The autocompleter callback yields an iterable (Option here); PHP
+ // treats a null result as an empty list of suggestions.
+ let callback = move |input: &str| callback(input).unwrap_or_default();
+ let autocomplete = match self.inner().autocomplete(
+ std::rc::Rc::clone(&output),
+ question,
+ &input_stream,
+ &callback,
+ ) {
+ Ok(value) => value,
+ Err(exception) => return Ok(Err(exception)),
+ };
+ ret = PhpMixed::String(if question.is_trimmable() {
+ shirabe_php_shim::trim(&autocomplete, None)
+ } else {
+ autocomplete
+ });
+ } else {
+ let mut r: PhpMixed = PhpMixed::Bool(false);
+ if question.is_hidden() {
+ match self.inner().get_hidden_response(
+ std::rc::Rc::clone(&output),
+ &input_stream,
+ question.is_trimmable(),
+ )? {
+ Ok(hidden_response) => {
+ r = PhpMixed::String(if question.is_trimmable() {
+ shirabe_php_shim::trim(&hidden_response, None)
+ } else {
+ hidden_response
+ });
+ }
+ Err(e) => {
+ if !question.is_hidden_fallback() {
+ return Err(e.into());
+ }
+ }
+ }
+ }
+
+ if matches!(r, PhpMixed::Bool(false)) {
+ let is_blocked = shirabe_php_shim::stream_get_meta_data(&input_stream)
+ .get("blocked")
+ .cloned()
+ .unwrap_or(PhpMixed::Bool(true));
+
+ if !shirabe_php_shim::boolval(&is_blocked) {
+ shirabe_php_shim::stream_set_blocking(&input_stream, true);
+ }
+
+ let read = self.inner().read_input(&input_stream, question);
+
+ if !shirabe_php_shim::boolval(&is_blocked) {
+ shirabe_php_shim::stream_set_blocking(&input_stream, false);
+ }
+
+ if matches!(read, PhpMixed::Bool(false)) {
+ return Ok(Err(MissingInputException::new("Aborted.".to_string())));
+ }
+ r = read;
+ if question.is_trimmable() {
+ r = PhpMixed::String(shirabe_php_shim::trim(&r.to_string(), None));
+ }
+ }
+ ret = r;
+ }
+
+ let mut ret = ret;
+ {
+ let borrowed = output.borrow();
+ if let Some(section_output) =
+ (*borrowed).as_any().downcast_ref::<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))
+ }
+
+ /// Validates an attempt (PHP private; on the trait so it can late-bind the hooks).
+ ///
+ /// @param callable $interviewer A callable that will ask for a question and return the result
+ ///
+ /// @return mixed The validated response
+ ///
+ /// @throws \Exception In case the max number of attempts has been reached and no valid response has been given
+ fn validate_attempts(
+ &self,
+ interviewer: &dyn Fn() -> anyhow::Result<Result<PhpMixed, MissingInputException>>,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ question: &impl QuestionInterface,
+ ) -> 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(std::rc::Rc::clone(&output), error);
+ }
+
+ let interviewed = match interviewer()? {
+ Ok(value) => value,
+ Err(missing) => return Ok(Err(missing)),
+ };
+
+ match question.get_validator().unwrap()(Some(interviewed)) {
+ Ok(value) => return Ok(Ok(value)),
+ Err(e) => {
+ // PHP: `catch (RuntimeException $e) { throw $e; } catch (\Exception $error) {}`.
+ // The validator return type is fixed to InvalidArgumentException here, so the
+ // RuntimeException rethrow branch is statically unreachable; record the error
+ // and retry.
+ error = Some(shirabe_php_shim::Exception::with_code(
+ e.get_message().to_string(),
+ e.get_code(),
+ ));
+ }
+ }
+ }
+
+ // throw $error;
+ Err(anyhow::Error::msg(
+ error
+ .map(|e| e.get_message().to_string())
+ .unwrap_or_default(),
+ ))
+ }
+
+ /// Outputs the question prompt (PHP protected; the overridable hook).
+ fn write_prompt(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ question: &impl QuestionInterface,
+ ) {
+ self.inner().write_prompt(output, question);
+ }
+
+ /// Outputs an error message (PHP protected; the overridable hook).
+ fn write_error(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ error: &shirabe_php_shim::Exception,
+ ) {
+ self.inner().write_error(output, error);
+ }
+}
+
+impl QuestionHelperInterface for QuestionHelper {
+ fn inner(&self) -> &QuestionHelper {
+ self
+ }
+
+ fn inner_mut(&mut self) -> &mut QuestionHelper {
+ self
+ }
+}
+
+impl QuestionHelper {
+ pub fn get_name(&self) -> String {
+ "question".to_string()
+ }
+
+ /// Prevents usage of stty.
+ pub fn disable_stty() {
+ STTY.store(false, std::sync::atomic::Ordering::SeqCst);
+ }
+
+ fn get_default_answer(&self, question: &impl QuestionInterface) -> PhpMixed {
+ let default = question.get_default();
+
+ if matches!(default, PhpMixed::Null) {
+ return default;
+ }
+
+ if let Some(validator) = question.get_validator() {
+ // call_user_func($question->getValidator(), $default)
+ return validator(Some(default)).unwrap();
+ } else if let Some(choice_question) = question.as_choice() {
+ let choices = choice_question.get_choices();
+
+ if !choice_question.is_multiselect() {
+ return choices
+ .get(&default.to_string())
+ .cloned()
+ .unwrap_or(default);
+ }
+
+ let default_parts = shirabe_php_shim::explode(",", &default.to_string());
+ let mut resolved: indexmap::IndexMap<String, 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).cloned().unwrap_or(PhpMixed::String(v));
+ resolved.insert(k.to_string(), value);
+ }
+
+ return PhpMixed::Array(resolved);
+ }
+
+ default
+ }
+
+ /// Outputs the question prompt.
+ pub(crate) fn write_prompt(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ question: &impl QuestionInterface,
+ ) {
+ let mut message = question.get_question().to_string();
+
+ if let Some(choice_question) = question.as_choice() {
+ let mut lines = vec![question.get_question().to_string()];
+ lines.extend(self.format_choice_question_choices(choice_question, "info"));
+ output
+ .borrow()
+ .writeln(&lines, output_interface::OUTPUT_NORMAL);
+
+ message = choice_question.get_prompt().to_string();
+ }
+
+ output
+ .borrow()
+ .write(&[message], false, output_interface::OUTPUT_NORMAL);
+ }
+
+ pub(crate) fn format_choice_question_choices(
+ &self,
+ question: &ChoiceQuestion,
+ tag: &str,
+ ) -> Vec<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(format!(
+ " [<{tag}>{}{padding}</{tag}>] {}",
+ key.clone(),
+ value.clone(),
+ ));
+ }
+
+ messages
+ }
+
+ /// Outputs an error message.
+ pub(crate) fn write_error(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ error: &shirabe_php_shim::Exception,
+ ) {
+ let message = if let Some(helper_set) = self.get_helper_set() {
+ let formatter = helper_set.borrow().get_formatter();
+
+ formatter.borrow().format_block(
+ FormatBlockMessages::String(error.get_message().to_string()),
+ "error",
+ false,
+ )
+ } else {
+ format!("<error>{}</error>", error.get_message())
+ };
+
+ output
+ .borrow()
+ .writeln(&[message], output_interface::OUTPUT_NORMAL);
+ }
+
+ /// Autocompletes a question.
+ fn autocomplete(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ question: &impl QuestionInterface,
+ input_stream: &shirabe_php_shim::PhpResource,
+ autocomplete: &dyn Fn(&str) -> Vec<PhpMixed>,
+ ) -> Result<String, MissingInputException> {
+ let cursor = Cursor::new(std::rc::Rc::clone(&output), Some(input_stream.clone()));
+
+ let mut full_choice = String::new();
+ let mut ret = String::new();
+
+ let mut i: i64 = 0;
+ let mut ofs: i64 = -1;
+ let mut matches = autocomplete(&ret);
+ let mut num_matches = matches.len() as i64;
+
+ let stty_mode = shirabe_php_shim::shell_exec("stty -g").unwrap_or_default();
+ let is_stdin = shirabe_php_shim::stream_get_meta_data(input_stream)
+ .get("uri")
+ .map(|uri| uri.to_string() == "php://stdin")
+ .unwrap_or(false);
+ let mut r = vec![input_stream.clone()];
+ let w: Vec<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 !shirabe_php_shim::feof(input_stream) {
+ while is_stdin
+ && Some(0)
+ == shirabe_php_shim::stream_select(
+ &mut r,
+ &mut w.clone(),
+ &mut w.clone(),
+ 0,
+ Some(100),
+ )
+ {
+ // Give signal handlers a chance to run
+ r = vec![input_stream.clone()];
+ }
+ let mut c = shirabe_php_shim::fread(input_stream, 1);
+
+ // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
+ if c.is_none()
+ || (ret.is_empty()
+ && c.as_deref() == Some("")
+ && matches!(question.get_default(), PhpMixed::Null))
+ {
+ shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode));
+ return Err(MissingInputException::new("Aborted.".to_string()));
+ } else if c.as_deref() == Some("\u{7f}") {
+ // Backspace Character
+ if 0 == num_matches && 0 != i {
+ i -= 1;
+ cursor.move_left(s(&full_choice).slice(-1, None).width(false));
+
+ full_choice = QuestionHelper::substr(Some(&full_choice), 0, Some(i));
+ }
+
+ if 0 == i {
+ ofs = -1;
+ matches = autocomplete(&ret);
+ num_matches = matches.len() as i64;
+ } else {
+ num_matches = 0;
+ }
+
+ // Pop the last character off the end of our string
+ ret = QuestionHelper::substr(Some(&ret), 0, Some(i));
+ } else if c.as_deref() == Some("\u{1b}") {
+ // Did we read an escape sequence?
+ let escape = shirabe_php_shim::fread(input_stream, 2).unwrap_or_default();
+ let cc = format!("{}{}", c.clone().unwrap_or_default(), escape);
+ c = Some(cc.clone());
+
+ // A = Up Arrow. B = Down Arrow
+ let c2 = cc.as_bytes().get(2).copied();
+ if c2 == Some(b'A') || c2 == Some(b'B') {
+ if c2 == Some(b'A') && -1 == ofs {
+ ofs = 0;
+ }
+
+ if 0 == num_matches {
+ continue;
+ }
+
+ ofs += if c2 == Some(b'A') { -1 } else { 1 };
+ ofs = (num_matches + ofs) % num_matches;
+ }
+ } else if shirabe_php_shim::ord(c.as_deref().unwrap_or("")) < 32 {
+ if c.as_deref() == Some("\t") || c.as_deref() == Some("\n") {
+ if num_matches > 0 && -1 != ofs {
+ ret = matches[ofs as usize].to_string();
+ // Echo out remaining chars for current match
+ let remaining_characters = shirabe_php_shim::substr(
+ &ret,
+ shirabe_php_shim::strlen(&shirabe_php_shim::trim(
+ &self.most_recently_entered_value(&full_choice),
+ None,
+ )),
+ None,
+ );
+ output.borrow().write(
+ std::slice::from_ref(&remaining_characters),
+ false,
+ output_interface::OUTPUT_NORMAL,
+ );
+ full_choice.push_str(&remaining_characters);
+ i = match shirabe_php_shim::mb_detect_encoding(&full_choice, None, true) {
+ None => shirabe_php_shim::strlen(&full_choice),
+ Some(encoding) => shirabe_php_shim::mb_strlen(&full_choice, &encoding),
+ };
+
+ let ret_for_filter = ret.clone();
+ matches = autocomplete(&ret)
+ .into_iter()
+ .filter(|m| {
+ ret_for_filter.is_empty()
+ || shirabe_php_shim::str_starts_with(
+ &m.to_string(),
+ &ret_for_filter,
+ )
+ })
+ .collect();
+ ofs = -1;
+ }
+
+ if c.as_deref() == Some("\n") {
+ output.borrow().write(
+ &[c.unwrap_or_default()],
+ false,
+ output_interface::OUTPUT_NORMAL,
+ );
+ break;
+ }
+
+ num_matches = 0;
+ }
+
+ continue;
+ } else {
+ let cur = c.clone().unwrap_or_default();
+ if "\u{80}" <= cur.as_str() {
+ let len = match shirabe_php_shim::str_bitand(&cur, "\u{f0}").as_str() {
+ "\u{c0}" => 1,
+ "\u{d0}" => 1,
+ "\u{e0}" => 2,
+ "\u{f0}" => 3,
+ _ => 0,
+ };
+ let extra = shirabe_php_shim::fread(input_stream, len).unwrap_or_default();
+ c = Some(format!("{}{}", cur, extra));
+ }
+
+ let cur = c.clone().unwrap_or_default();
+ output.borrow().write(
+ std::slice::from_ref(&cur),
+ false,
+ output_interface::OUTPUT_NORMAL,
+ );
+ ret.push_str(&cur);
+ full_choice.push_str(&cur);
+ i += 1;
+
+ let mut temp_ret = ret.clone();
+
+ if let Some(choice_question) = question.as_choice()
+ && choice_question.is_multiselect()
+ {
+ temp_ret = self.most_recently_entered_value(&full_choice);
+ }
+
+ num_matches = 0;
+ ofs = 0;
+
+ for value in autocomplete(&ret) {
+ // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle)
+ if shirabe_php_shim::str_starts_with(&value.to_string(), &temp_ret) {
+ if (num_matches as usize) < matches.len() {
+ matches[num_matches as usize] = value;
+ } else {
+ matches.push(value);
+ }
+ num_matches += 1;
+ }
+ }
+ }
+
+ cursor.clear_line_after();
+
+ if num_matches > 0 && -1 != ofs {
+ cursor.save_position();
+ // Write highlighted text, complete the partially entered response
+ let characters_entered = shirabe_php_shim::strlen(&shirabe_php_shim::trim(
+ &self.most_recently_entered_value(&full_choice),
+ None,
+ ));
+ output.borrow().write(
+ &[format!(
+ "<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));
+
+ Ok(full_choice)
+ }
+
+ fn most_recently_entered_value(&self, entered: &str) -> String {
+ // Determine the most recent value that the user entered
+ if !shirabe_php_shim::str_contains(entered, ",") {
+ return entered.to_string();
+ }
+
+ let choices = shirabe_php_shim::explode(",", entered);
+ let last_choice = shirabe_php_shim::trim(&choices[choices.len() - 1], None);
+ if !last_choice.is_empty() {
+ return last_choice;
+ }
+
+ entered.to_string()
+ }
+
+ /// Gets a hidden response from user.
+ ///
+ /// @param resource $inputStream The handler resource
+ /// @param bool $trimmable Is the answer trimmable
+ ///
+ /// @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden
+ fn get_hidden_response(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ input_stream: &shirabe_php_shim::PhpResource,
+ trimmable: bool,
+ ) -> anyhow::Result<Result<String, RuntimeException>> {
+ if cfg!(windows) {
+ let mut exe = format!(
+ "{}/../Resources/bin/hiddeninput.exe",
+ shirabe_php_shim::dir()
+ );
+
+ // handle code running from a phar
+ let mut tmp_exe: Option<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::new(
+ "Unable to hide the response.".to_string(),
+ )));
+ }
+
+ let value = shirabe_php_shim::fgets(input_stream, Some(4096));
+
+ if STTY.load(std::sync::atomic::Ordering::SeqCst) && Terminal::has_stty_available() {
+ shirabe_php_shim::shell_exec(&format!("stty {}", stty_mode));
+ }
+
+ let mut value = match value {
+ Some(value) => value,
+ None => {
+ return Err(MissingInputException::new("Aborted.".to_string()).into());
+ }
+ };
+ if trimmable {
+ value = shirabe_php_shim::trim(&value, None);
+ }
+ output
+ .borrow()
+ .writeln(&["".to_string()], output_interface::OUTPUT_NORMAL);
+
+ Ok(Ok(value))
+ }
+
+ fn is_interactive_input(&self, input_stream: &shirabe_php_shim::PhpResource) -> bool {
+ let uri = shirabe_php_shim::stream_get_meta_data(input_stream)
+ .get("uri")
+ .map(|uri| uri.to_string());
+ if uri.as_deref() != Some("php://stdin") {
+ return false;
+ }
+
+ let mut stdin_is_interactive = STDIN_IS_INTERACTIVE.lock().unwrap();
+ if let Some(value) = *stdin_is_interactive {
+ return value;
+ }
+
+ let value = shirabe_php_shim::stream_isatty_resource(
+ &shirabe_php_shim::php_fopen_resource("php://stdin", "r"),
+ );
+ *stdin_is_interactive = Some(value);
+ value
+ }
+
+ /// Reads one or more lines of input and returns what is read.
+ ///
+ /// @param resource $inputStream The handler resource
+ /// @param Question $question The question being asked
+ ///
+ /// @return string|false The input received, false in case input could not be read
+ fn read_input(
+ &self,
+ input_stream: &shirabe_php_shim::PhpResource,
+ question: &impl QuestionInterface,
+ ) -> PhpMixed {
+ if !question.is_multiline() {
+ let cp = self.set_io_codepage();
+ let ret = shirabe_php_shim::fgets(input_stream, Some(4096));
+
+ return self.reset_io_codepage(
+ cp,
+ ret.map(PhpMixed::String).unwrap_or(PhpMixed::Bool(false)),
+ );
+ }
+
+ let multi_line_stream_reader = self.clone_input_stream(input_stream);
+ let multi_line_stream_reader = match multi_line_stream_reader {
+ Some(reader) => reader,
+ None => return PhpMixed::Bool(false),
+ };
+
+ let mut ret = String::new();
+ let cp = self.set_io_codepage();
+ loop {
+ let char = shirabe_php_shim::fgetc(&multi_line_stream_reader);
+ let char = match char {
+ Some(char) => char,
+ None => break,
+ };
+ if shirabe_php_shim::PHP_EOL == format!("{}{}", ret, char) {
+ break;
+ }
+ ret.push_str(&char);
+ }
+
+ self.reset_io_codepage(cp, PhpMixed::String(ret))
+ }
+
+ /// Sets console I/O to the host code page.
+ ///
+ /// @return int Previous code page in IBM/EBCDIC format
+ fn set_io_codepage(&self) -> i64 {
+ if shirabe_php_shim::function_exists("sapi_windows_cp_set") {
+ let cp = shirabe_php_shim::sapi_windows_cp_get(None);
+ shirabe_php_shim::sapi_windows_cp_set(shirabe_php_shim::sapi_windows_cp_get(Some(
+ "oem",
+ )));
+
+ return cp;
+ }
+
+ 0
+ }
+
+ /// Sets console I/O to the specified code page and converts the user input.
+ fn reset_io_codepage(&self, cp: i64, input: PhpMixed) -> PhpMixed {
+ let mut input = input;
+ if 0 != cp {
+ shirabe_php_shim::sapi_windows_cp_set(cp);
+
+ if !matches!(input, PhpMixed::Bool(false)) && input.to_string() != "" {
+ input = PhpMixed::String(shirabe_php_shim::sapi_windows_cp_conv(
+ shirabe_php_shim::sapi_windows_cp_get(Some("oem")),
+ cp,
+ &input.to_string(),
+ ));
+ }
+ }
+
+ input
+ }
+
+ /// Clones an input stream in order to act on one instance of the same
+ /// stream without affecting the other instance.
+ ///
+ /// @param resource $inputStream The handler resource
+ ///
+ /// @return resource|null The cloned resource, null in case it could not be cloned
+ fn clone_input_stream(
+ &self,
+ input_stream: &shirabe_php_shim::PhpResource,
+ ) -> Option<shirabe_php_shim::PhpResource> {
+ let stream_meta_data = shirabe_php_shim::stream_get_meta_data(input_stream);
+ let seekable = stream_meta_data
+ .get("seekable")
+ .cloned()
+ .unwrap_or(PhpMixed::Bool(false));
+ let mode = stream_meta_data
+ .get("mode")
+ .map(|m| m.to_string())
+ .unwrap_or_else(|| "rb".to_string());
+ let uri = stream_meta_data.get("uri").map(|u| u.to_string());
+
+ let uri = uri?;
+
+ let clone_stream = shirabe_php_shim::fopen(&uri, &mode).ok()?;
+
+ // For seekable and writable streams, add all the same data to the
+ // cloned stream and then seek to the same offset.
+ if matches!(seekable, PhpMixed::Bool(true)) && !["r", "rb", "rt"].contains(&mode.as_str()) {
+ let offset = shirabe_php_shim::ftell(input_stream).unwrap_or(0);
+ shirabe_php_shim::rewind(input_stream);
+ shirabe_php_shim::stream_copy_to_stream(input_stream, &clone_stream);
+ shirabe_php_shim::fseek(input_stream, offset, shirabe_php_shim::SEEK_SET);
+ shirabe_php_shim::fseek(&clone_stream, offset, shirabe_php_shim::SEEK_SET);
+ }
+
+ Some(clone_stream)
+ }
+
+ /// Helper::substr proxy (inherited static helper).
+ fn substr(string: Option<&str>, from: i64, length: Option<i64>) -> String {
+ Helper::substr(string.unwrap_or(""), from, length)
+ }
+}
+
+/// PHP `__FILE__` magic constant. The executing code lives in the Shirabe binary itself, which is
+/// the closest analogue for a native executable; it never carries the `phar:` scheme, so the
+/// phar-relocation branch in `get_hidden_response` correctly never triggers.
+fn magic_file() -> String {
+ std::env::current_exe()
+ .expect("current executable path")
+ .display()
+ .to_string()
+}
+
+impl HelperInterface for QuestionHelper {
+ fn set_helper_set(&mut self, helper_set: Option<std::rc::Rc<std::cell::RefCell<HelperSet>>>) {
+ self.inner.set_helper_set(helper_set);
+ }
+
+ fn get_helper_set(&self) -> Option<std::rc::Rc<std::cell::RefCell<HelperSet>>> {
+ self.inner.get_helper_set()
+ }
+
+ fn get_name(&self) -> String {
+ self.get_name()
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs b/crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs
new file mode 100644
index 00000000..9de40dd4
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/symfony_question_helper.rs
@@ -0,0 +1,163 @@
+//! ref: composer/vendor/symfony/console/Helper/SymfonyQuestionHelper.php
+
+use crate::formatter::output_formatter::OutputFormatter;
+use crate::helper::question_helper::{QuestionHelper, QuestionHelperInterface};
+use crate::output::output_interface;
+use crate::output::output_interface::OutputInterface;
+use crate::question::QuestionInterface;
+use crate::style::style_interface::StyleInterface;
+use crate::style::symfony_style::SymfonyStyle;
+use shirabe_php_shim::PhpMixed;
+use std::ops::{Deref, DerefMut};
+
+/// Symfony Style Guide compliant question helper.
+#[derive(Debug, Default)]
+pub struct SymfonyQuestionHelper {
+ inner: QuestionHelper,
+}
+
+impl SymfonyQuestionHelper {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ fn get_eof_shortcut(&self) -> String {
+ if shirabe_php_shim::php_os_family() == "Windows" {
+ return "<comment>Ctrl+Z</comment> then <comment>Enter</comment>".to_string();
+ }
+
+ "<comment>Ctrl+D</comment>".to_string()
+ }
+}
+
+impl QuestionHelperInterface for SymfonyQuestionHelper {
+ fn inner(&self) -> &QuestionHelper {
+ &self.inner
+ }
+
+ fn inner_mut(&mut self) -> &mut QuestionHelper {
+ &mut self.inner
+ }
+
+ /// {@inheritdoc}
+ fn write_prompt(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ question: &impl QuestionInterface,
+ ) {
+ let mut text = OutputFormatter::escape_trailing_backslash(question.get_question());
+ let default = question.get_default();
+
+ if question.is_multiline() {
+ text += &format!(" (press {} to continue)", self.get_eof_shortcut());
+ }
+
+ // switch (true)
+ if matches!(default, PhpMixed::Null) {
+ text = format!(" <info>{}</info>:", text);
+ } else if question.as_confirmation().is_some() {
+ text = format!(
+ " <info>{} (yes/no)</info> [<comment>{}</comment>]:",
+ text,
+ if shirabe_php_shim::boolval(&default) {
+ "yes"
+ } else {
+ "no"
+ },
+ );
+ } else if let Some(choice_question) = question.as_choice().filter(|q| q.is_multiselect()) {
+ let choices = choice_question.get_choices();
+ let default_parts = shirabe_php_shim::explode(",", &default.to_string());
+
+ let resolved: Vec<String> = default_parts
+ .iter()
+ .map(|value| {
+ choices
+ .get(&shirabe_php_shim::trim(value, None))
+ .map(|v| v.to_string())
+ .unwrap()
+ })
+ .collect();
+
+ text = format!(
+ " <info>{}</info> [<comment>{}</comment>]:",
+ text,
+ OutputFormatter::escape(&resolved.join(", ")).unwrap(),
+ );
+ } else if let Some(choice_question) = question.as_choice() {
+ let choices = choice_question.get_choices();
+ text = format!(
+ " <info>{}</info> [<comment>{}</comment>]:",
+ text,
+ OutputFormatter::escape(
+ &choices
+ .get(&default.to_string())
+ .cloned()
+ .unwrap_or(default)
+ .to_string(),
+ )
+ .unwrap(),
+ );
+ } else {
+ text = format!(
+ " <info>{}</info> [<comment>{}</comment>]:",
+ text,
+ OutputFormatter::escape(&default.to_string()).unwrap(),
+ );
+ }
+
+ output
+ .borrow()
+ .writeln(&[text], output_interface::OUTPUT_NORMAL);
+
+ let mut prompt = " > ".to_string();
+
+ if let Some(choice_question) = question.as_choice() {
+ output.borrow().writeln(
+ &self
+ .inner
+ .format_choice_question_choices(choice_question, "comment"),
+ output_interface::OUTPUT_NORMAL,
+ );
+
+ prompt = choice_question.get_prompt().to_string();
+ }
+
+ output
+ .borrow()
+ .write(&[prompt], false, output_interface::OUTPUT_NORMAL);
+ }
+
+ /// {@inheritdoc}
+ fn write_error(
+ &self,
+ output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ error: &shirabe_php_shim::Exception,
+ ) {
+ {
+ let mut borrowed = output.borrow_mut();
+ if let Some(style) = (*borrowed).as_any_mut().downcast_mut::<SymfonyStyle>() {
+ style.new_line(1);
+ style.error(PhpMixed::String(error.get_message().to_string()));
+
+ return;
+ }
+ }
+
+ self.inner.write_error(output, error);
+ }
+}
+
+impl Deref for SymfonyQuestionHelper {
+ type Target = QuestionHelper;
+
+ fn deref(&self) -> &Self::Target {
+ &self.inner
+ }
+}
+
+impl DerefMut for SymfonyQuestionHelper {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.inner
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/table.rs b/crates/shirabe-symfony-console/src/helper/table.rs
new file mode 100644
index 00000000..e2dec0c1
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/table.rs
@@ -0,0 +1,1443 @@
+//! ref: composer/vendor/symfony/console/Helper/Table.php
+
+use crate::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::exception::runtime_exception::RuntimeException;
+use crate::formatter::output_formatter::OutputFormatter;
+use crate::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface;
+use crate::helper::helper::Helper;
+use crate::helper::table_cell::{TableCell, TableCellOption};
+use crate::helper::table_cell_style::TableCellStyle;
+use crate::helper::table_rows::TableRows;
+use crate::helper::table_separator::TableSeparator;
+use crate::helper::table_style::TableStyle;
+use crate::output::console_section_output::ConsoleSectionOutput;
+use crate::output::output_interface::OutputInterface;
+use indexmap::IndexMap;
+use shirabe_pcre::preg::Preg;
+use shirabe_php_shim::{PhpMixed, php_regex};
+
+/// A single cell within a table row.
+///
+/// PHP types a cell as `TableCell|string|int|null` (scalars are stringified). Because
+/// `TableSeparator extends TableCell`, a separator can in principle appear as a cell, so it has its
+/// own variant; `is_table_cell` reports `true` for it, mirroring `instanceof TableCell`.
+#[derive(Debug, Clone)]
+pub enum Cell {
+ Null,
+ Value(String),
+ Cell(TableCell),
+ Separator(TableSeparator),
+}
+
+impl Cell {
+ /// PHP `$cell instanceof TableCell` (true for `TableSeparator`, which extends `TableCell`).
+ fn is_table_cell(&self) -> bool {
+ matches!(self, Cell::Cell(_) | Cell::Separator(_))
+ }
+
+ /// PHP `$cell instanceof TableSeparator`.
+ fn is_table_separator(&self) -> bool {
+ matches!(self, Cell::Separator(_))
+ }
+
+ fn colspan(&self) -> i64 {
+ match self {
+ Cell::Cell(c) => c.get_colspan(),
+ Cell::Separator(s) => s.get_colspan(),
+ _ => 1,
+ }
+ }
+
+ fn rowspan(&self) -> i64 {
+ match self {
+ Cell::Cell(c) => c.get_rowspan(),
+ Cell::Separator(s) => s.get_rowspan(),
+ _ => 1,
+ }
+ }
+
+ fn style(&self) -> Option<std::rc::Rc<TableCellStyle>> {
+ match self {
+ Cell::Cell(c) => c.get_style(),
+ Cell::Separator(s) => s.get_style(),
+ _ => None,
+ }
+ }
+
+ /// PHP `(string) $cell`.
+ fn to_php_string(&self) -> String {
+ match self {
+ Cell::Null | Cell::Separator(_) => String::new(),
+ Cell::Value(s) => s.clone(),
+ Cell::Cell(c) => c.to_string(),
+ }
+ }
+
+ /// PHP truthiness / `!empty($cell)` for a cell value: only "" and "0" (and null) are falsy;
+ /// any object is truthy.
+ fn is_truthy(&self) -> bool {
+ match self {
+ Cell::Null => false,
+ Cell::Value(s) => !s.is_empty() && s != "0",
+ Cell::Cell(_) | Cell::Separator(_) => true,
+ }
+ }
+
+ fn is_null(&self) -> bool {
+ matches!(self, Cell::Null)
+ }
+}
+
+impl From<String> for Cell {
+ fn from(value: String) -> Self {
+ Cell::Value(value)
+ }
+}
+
+impl From<&str> for Cell {
+ fn from(value: &str) -> Self {
+ Cell::Value(value.to_string())
+ }
+}
+
+impl From<TableCell> for Cell {
+ fn from(value: TableCell) -> Self {
+ Cell::Cell(value)
+ }
+}
+
+/// Bridges PHP-typed cell values built elsewhere in the codebase (where rows are still assembled as
+/// `PhpMixed`) onto the typed cell. A cell is a scalar or null; collections stringify as PHP would.
+impl From<PhpMixed> for Cell {
+ fn from(value: PhpMixed) -> Self {
+ match value {
+ PhpMixed::Null => Cell::Null,
+ other => Cell::Value(shirabe_php_shim::to_string(&other)),
+ }
+ }
+}
+
+/// A table row.
+///
+/// PHP types a row as `array<int, Cell>|TableSeparator`. The header/body boundary that PHP creates
+/// as a fresh `TableSeparator` and recognizes by object identity (`$divider === $row`) is modeled
+/// here by its own [`Row::HeaderDivider`] variant rather than by reference identity: it counts as a
+/// separator for layout (`is_table_separator`), but unlike a user-supplied [`Row::Separator`] it
+/// renders no separator line — it only flips the header/first-row state during rendering.
+#[derive(Debug, Clone)]
+pub enum Row {
+ HeaderDivider,
+ Separator(TableSeparator),
+ Cells(Vec<Cell>),
+}
+
+impl Row {
+ /// PHP `$row instanceof TableSeparator` (also true for the internal header/body divider).
+ fn is_table_separator(&self) -> bool {
+ matches!(self, Row::HeaderDivider | Row::Separator(_))
+ }
+
+ /// PHP `$divider === $row`: identifies the internal header/body boundary marker.
+ fn is_divider(&self) -> bool {
+ matches!(self, Row::HeaderDivider)
+ }
+
+ /// The row's cells, or an empty list for a separator (PHP `foreach` over a separator object,
+ /// which exposes no public properties, yields nothing).
+ fn cells(&self) -> Vec<Cell> {
+ match self {
+ Row::Cells(cells) => cells.clone(),
+ _ => Vec::new(),
+ }
+ }
+
+ /// PHP `!$row`: an empty cell array is falsy; a separator is an object and thus truthy.
+ fn is_truthy(&self) -> bool {
+ match self {
+ Row::Cells(cells) => !cells.is_empty(),
+ _ => true,
+ }
+ }
+
+ /// PHP `isset($row[$i])`: `false` for a missing or null cell.
+ fn get_cell_isset(&self, index: i64) -> Option<Cell> {
+ match self {
+ Row::Cells(cells) => match cells.get(index as usize) {
+ Some(Cell::Null) | None => None,
+ Some(cell) => Some(cell.clone()),
+ },
+ _ => None,
+ }
+ }
+}
+
+impl From<Vec<Cell>> for Row {
+ fn from(cells: Vec<Cell>) -> Self {
+ Row::Cells(cells)
+ }
+}
+
+/// Bridges PHP-typed rows built elsewhere (still assembled as `PhpMixed`) onto the typed row.
+impl From<PhpMixed> for Row {
+ fn from(value: PhpMixed) -> Self {
+ match value {
+ PhpMixed::List(items) => Row::Cells(items.into_iter().map(Cell::from).collect()),
+ PhpMixed::Array(map) => Row::Cells(map.into_values().map(Cell::from).collect()),
+ PhpMixed::Null => Row::Cells(Vec::new()),
+ other => Row::Cells(vec![Cell::from(other)]),
+ }
+ }
+}
+
+/// A table or column style argument. PHP types it as `string|TableStyle`: either the name of a
+/// registered style or a `TableStyle` instance.
+#[derive(Debug, Clone)]
+pub enum StyleName {
+ Name(String),
+ Style(TableStyle),
+}
+
+impl From<&str> for StyleName {
+ fn from(name: &str) -> Self {
+ StyleName::Name(name.to_string())
+ }
+}
+
+impl From<String> for StyleName {
+ fn from(name: String) -> Self {
+ StyleName::Name(name)
+ }
+}
+
+impl From<TableStyle> for StyleName {
+ fn from(style: TableStyle) -> Self {
+ StyleName::Style(style)
+ }
+}
+
+/// PHP `$row[$index] = $value`: assigns at the integer key, growing the (dense) row with null
+/// cells to reach `index` when necessary.
+fn set_cell(row: &mut Vec<Cell>, index: i64, value: Cell) {
+ let index = index as usize;
+ while row.len() <= index {
+ row.push(Cell::Null);
+ }
+ row[index] = value;
+}
+
+/// Provides helpers to display a table.
+#[derive(Debug)]
+pub struct Table {
+ header_title: Option<String>,
+ footer_title: Option<String>,
+
+ /// Table headers.
+ headers: Vec<Row>,
+
+ /// Table rows.
+ rows: Vec<Row>,
+ horizontal: bool,
+
+ /// Column widths cache.
+ effective_column_widths: IndexMap<i64, i64>,
+
+ /// Number of columns cache.
+ number_of_columns: Option<i64>,
+
+ output: std::rc::Rc<std::cell::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 {
+ let mut styles_guard = styles().lock().unwrap();
+ if styles_guard.is_none() {
+ *styles_guard = Some(Self::init_styles());
+ }
+ drop(styles_guard);
+
+ let mut this = Self {
+ header_title: None,
+ footer_title: None,
+ headers: Vec::new(),
+ rows: Vec::new(),
+ horizontal: false,
+ effective_column_widths: IndexMap::new(),
+ number_of_columns: None,
+ output,
+ style: TableStyle::default(),
+ column_styles: IndexMap::new(),
+ column_widths: IndexMap::new(),
+ column_max_widths: IndexMap::new(),
+ rendered: false,
+ };
+
+ this.set_style(StyleName::from("default"));
+
+ this
+ }
+
+ /// Sets a style definition.
+ pub fn set_style_definition(name: String, style: TableStyle) {
+ let mut styles_guard = styles().lock().unwrap();
+ if styles_guard.is_none() {
+ *styles_guard = Some(Self::init_styles());
+ }
+
+ styles_guard.as_mut().unwrap().insert(name, style);
+ }
+
+ /// Gets a style definition by name.
+ pub fn get_style_definition(
+ name: String,
+ ) -> anyhow::Result<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) {
+ return Ok(Ok(style.clone()));
+ }
+
+ Ok(Err(InvalidArgumentException::new(format!(
+ "Style \"{}\" is not defined.",
+ name
+ ))))
+ }
+
+ /// Sets table style.
+ ///
+ /// `$name` is the style name or a TableStyle instance.
+ pub fn set_style(
+ &mut self,
+ name: StyleName,
+ ) -> anyhow::Result<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: StyleName,
+ ) -> 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<Cell>) -> &mut Self {
+ // PHP wraps a flat list of cells into a single header row. (Multi-row headers, which PHP
+ // also accepts, are not used in this codebase and are not modeled by the typed API.)
+ self.headers = if headers.is_empty() {
+ Vec::new()
+ } else {
+ vec![Row::Cells(headers)]
+ };
+
+ self
+ }
+
+ pub fn set_rows(&mut self, rows: Vec<Row>) -> &mut Self {
+ self.rows = Vec::new();
+
+ self.add_rows(rows)
+ }
+
+ pub fn add_rows(&mut self, rows: Vec<Row>) -> &mut Self {
+ for row in rows {
+ self.add_row(row);
+ }
+
+ self
+ }
+
+ pub fn add_row(&mut self, row: Row) -> &mut Self {
+ // PHP `array_values($row)` reindexing is the identity on a positional cell vector, and the
+ // "row must be an array or a TableSeparator" check is now guaranteed by the type.
+ self.rows.push(row);
+
+ self
+ }
+
+ /// Adds a row to the table, and re-renders the table.
+ pub fn append_row(&mut self, row: Row) -> anyhow::Result<Result<&mut Self, RuntimeException>> {
+ if !Self::output_is_console_section(&self.output) {
+ return Ok(Err(RuntimeException::new(format!(
+ "Output should be an instance of \"{}\" when calling \"{}\".",
+ "Symfony\\Component\\Console\\Output\\ConsoleSectionOutput",
+ "Symfony\\Component\\Console\\Helper\\Table::appendRow",
+ ))));
+ }
+
+ if self.rendered {
+ // TODO(phase-c): downcast output to ConsoleSectionOutput to call clear().
+ let _ = ConsoleSectionOutput::clear;
+ let row_count = self.calculate_row_count();
+ let _ = row_count;
+ todo!()
+ }
+
+ self.add_row(row);
+ self.render();
+
+ Ok(Ok(self))
+ }
+
+ pub fn set_row(&mut self, column: i64, row: Vec<Cell>) -> &mut Self {
+ // PHP indexes $this->rows by arbitrary key; sparse assignment over a positional Vec is not
+ // modeled and has no callers.
+ let _ = (column, row);
+ // TODO(phase-c): sparse `$this->rows[$column] = $row` over a positional row vector.
+ todo!()
+ }
+
+ pub fn set_header_title(&mut self, title: Option<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 rows: Vec<Row> = if self.horizontal {
+ let mut horizontal_rows: IndexMap<i64, Vec<Cell>> = IndexMap::new();
+ let header0 = self.headers.first().map(|h| h.cells()).unwrap_or_default();
+ for (i, header) in header0.into_iter().enumerate() {
+ let i = i as i64;
+ horizontal_rows.insert(i, vec![header]);
+ for row in &self.rows {
+ if row.is_table_separator() {
+ continue;
+ }
+ if let Some(cell) = row.get_cell_isset(i) {
+ let entry = horizontal_rows.get_mut(&i).unwrap();
+ entry.push(cell);
+ } else {
+ let first = horizontal_rows.get(&i).unwrap().first().cloned();
+ let is_title_noop = match first {
+ Some(ref c) if c.is_table_cell() => c.colspan() >= 2,
+ _ => false,
+ };
+ if is_title_noop {
+ // Noop, there is a "title"
+ } else {
+ let entry = horizontal_rows.get_mut(&i).unwrap();
+ entry.push(Cell::Null);
+ }
+ }
+ }
+ }
+ horizontal_rows.into_values().map(Row::Cells).collect()
+ } else {
+ let mut merged = self.headers.clone();
+ merged.push(Row::HeaderDivider);
+ merged.extend(self.rows.clone());
+ merged
+ };
+
+ self.calculate_number_of_columns(&rows);
+
+ let row_groups = self.build_table_rows(rows);
+ self.calculate_columns_width(&row_groups);
+
+ let mut is_header = !self.horizontal;
+ let mut is_first_row = self.horizontal;
+ let mut has_title =
+ self.header_title.is_some() && !self.header_title.as_deref().unwrap_or("").is_empty();
+
+ for row_group in &row_groups {
+ let mut is_header_separator_rendered = false;
+
+ for row in row_group {
+ if row.is_divider() {
+ is_header = false;
+ is_first_row = true;
+
+ continue;
+ }
+
+ if row.is_table_separator() {
+ self.render_row_separator(SEPARATOR_MID, None, None);
+
+ continue;
+ }
+
+ if !row.is_truthy() {
+ continue;
+ }
+
+ if is_header && !is_header_separator_rendered {
+ self.render_row_separator(
+ if is_header {
+ SEPARATOR_TOP
+ } else {
+ SEPARATOR_TOP_BOTTOM
+ },
+ if has_title {
+ self.header_title.clone()
+ } else {
+ None
+ },
+ if has_title {
+ Some(self.style.get_header_title_format())
+ } else {
+ None
+ },
+ );
+ has_title = false;
+ is_header_separator_rendered = true;
+ }
+
+ if is_first_row {
+ self.render_row_separator(
+ if is_header {
+ SEPARATOR_TOP
+ } else {
+ SEPARATOR_TOP_BOTTOM
+ },
+ if has_title {
+ self.header_title.clone()
+ } else {
+ None
+ },
+ if has_title {
+ Some(self.style.get_header_title_format())
+ } else {
+ None
+ },
+ );
+ is_first_row = false;
+ has_title = false;
+ }
+
+ if self.horizontal {
+ self.render_row(
+ row.cells(),
+ self.style.get_cell_row_format(),
+ Some(self.style.get_cell_header_format()),
+ );
+ } else {
+ self.render_row(
+ row.cells(),
+ if is_header {
+ self.style.get_cell_header_format()
+ } else {
+ self.style.get_cell_row_format()
+ },
+ None,
+ );
+ }
+ }
+ }
+ self.render_row_separator(
+ SEPARATOR_BOTTOM,
+ self.footer_title.clone(),
+ Some(self.style.get_footer_title_format()),
+ );
+
+ self.cleanup();
+ self.rendered = true;
+ }
+
+ /// Renders horizontal header separator.
+ fn render_row_separator(
+ &self,
+ r#type: i64,
+ title: Option<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 = (markup_length - title_length) / 2;
+ if shirabe_php_shim::mb_detect_encoding(&markup, None, true).is_none() {
+ markup = shirabe_php_shim::substr_replace(
+ &markup,
+ &formatted_title,
+ title_start as usize,
+ title_length as usize,
+ );
+ } else {
+ markup = format!(
+ "{}{}{}",
+ shirabe_php_shim::mb_substr(&markup, 0, Some(title_start), None),
+ formatted_title,
+ shirabe_php_shim::mb_substr(&markup, title_start + title_length, None, None),
+ );
+ }
+ }
+
+ self.output.borrow().writeln(
+ &[shirabe_php_shim::sprintf(
+ &self.style.get_border_format(),
+ &[PhpMixed::from(markup)],
+ )],
+ crate::output::output_interface::OUTPUT_NORMAL,
+ );
+ }
+
+ /// Renders vertical column separator.
+ fn render_column_separator(&self, r#type: i64) -> String {
+ let borders = self.style.get_border_chars();
+
+ shirabe_php_shim::sprintf(
+ &self.style.get_border_format(),
+ &[PhpMixed::from(if BORDER_OUTSIDE == r#type {
+ borders[1].clone()
+ } else {
+ borders[3].clone()
+ })],
+ )
+ }
+
+ /// Renders table row.
+ fn render_row(&self, row: Vec<Cell>, 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::output::output_interface::OUTPUT_NORMAL,
+ );
+ }
+
+ /// Renders table cell with padding.
+ fn render_cell(&self, row: &[Cell], column: i64, cell_format: String) -> String {
+ let cell = row
+ .get(column as usize)
+ .cloned()
+ .unwrap_or(Cell::Value(String::new()));
+ let mut width = self.effective_column_widths[&column];
+ if cell.is_table_cell() && cell.colspan() > 1 {
+ // add the width of the following columns(numbers of colspan).
+ for next_column in (column + 1)..=(column + cell.colspan() - 1) {
+ width +=
+ self.get_column_separator_width() + self.effective_column_widths[&next_column];
+ }
+ }
+
+ // str_pad won't work properly with multi-byte strings, we need to fix the padding
+ let cell_str = cell.to_php_string();
+ if let Some(encoding) = shirabe_php_shim::mb_detect_encoding(&cell_str, None, true) {
+ width += shirabe_php_shim::strlen(&cell_str)
+ - shirabe_php_shim::mb_strwidth(&cell_str, Some(&encoding));
+ }
+
+ let style = self.get_column_style(column);
+
+ if cell.is_table_separator() {
+ return shirabe_php_shim::sprintf(
+ &style.get_border_format(),
+ &[PhpMixed::from(shirabe_php_shim::str_repeat(
+ &style.get_border_chars()[2],
+ width as usize,
+ ))],
+ );
+ }
+
+ width += Helper::length(&cell_str) - Helper::length(&self.remove_decoration(&cell_str));
+ let mut content = shirabe_php_shim::sprintf(
+ &style.get_cell_row_content_format(),
+ &[PhpMixed::from(cell_str.clone())],
+ );
+
+ let mut cell_format = cell_format;
+ let mut pad_type = style.get_pad_type();
+ if cell.is_table_cell() && cell.style().is_some() {
+ let is_not_styled_by_tag = !Preg::is_match(
+ php_regex!("/^<(\\w+|(\\w+=[\\w,]+;?)*)>.+<\\/(\\w+|(\\w+=\\w+;?)*)?>$/"),
+ &cell_str,
+ );
+ if is_not_styled_by_tag {
+ let cell_style = cell.style().unwrap();
+ match cell_style.get_cell_format() {
+ Some(fmt) => cell_format = fmt,
+ None => {
+ let tag = shirabe_php_shim::http_build_query_mixed(
+ &cell_style.get_tag_options(),
+ "",
+ ";",
+ );
+ cell_format = format!("<{}>%s</>", tag);
+ }
+ }
+
+ if shirabe_php_shim::strstr(&content, "</>").is_some() {
+ content = shirabe_php_shim::str_replace("</>", "", &content);
+ width -= 3;
+ }
+ if shirabe_php_shim::strstr(&content, "<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 = cell.style().unwrap().get_pad_by_align();
+ }
+
+ shirabe_php_shim::sprintf(
+ &cell_format,
+ &[PhpMixed::from(shirabe_php_shim::str_pad(
+ &content,
+ width as usize,
+ &style.get_padding_char(),
+ pad_type,
+ ))],
+ )
+ }
+
+ /// Calculate number of columns for this table.
+ fn calculate_number_of_columns(&mut self, rows: &[Row]) {
+ let mut columns = vec![0i64];
+ for row in rows {
+ if row.is_table_separator() {
+ continue;
+ }
+
+ columns.push(self.get_number_of_columns(&row.cells()));
+ }
+
+ self.number_of_columns = Some(*columns.iter().max().unwrap());
+ }
+
+ fn build_table_rows(&mut self, rows: Vec<Row>) -> TableRows {
+ let mut rows = rows;
+ let mut unmerged_rows: IndexMap<i64, IndexMap<i64, Vec<Cell>>> = IndexMap::new();
+ let mut row_key = 0i64;
+ while row_key < rows.len() as i64 {
+ rows = self.fill_next_rows(rows, row_key);
+
+ // Remove any new line breaks and replace it with a new line
+ let current = rows[row_key as usize].cells();
+ for (column, cell) in current.iter().enumerate() {
+ let column = column as i64;
+ let mut cell = cell.clone();
+ let colspan = if cell.is_table_cell() {
+ cell.colspan()
+ } else {
+ 1
+ };
+
+ if self.column_max_widths.contains_key(&column)
+ && Helper::width(&self.remove_decoration(&cell.to_php_string()))
+ > self.column_max_widths[&column]
+ {
+ let wrapped = self.format_and_wrap(
+ &cell.to_php_string(),
+ self.column_max_widths[&column] * colspan,
+ );
+ cell = Cell::Value(wrapped);
+ }
+ let cell_str = cell.to_php_string();
+ if shirabe_php_shim::strstr(&cell_str, "\n").is_none() {
+ continue;
+ }
+ let eol = if shirabe_php_shim::str_contains(&cell_str, "\r\n") {
+ "\r\n"
+ } else {
+ "\n"
+ };
+ let escaped = shirabe_php_shim::implode(
+ eol,
+ &shirabe_php_shim::explode(eol, &cell_str)
+ .iter()
+ .map(|line| OutputFormatter::escape_trailing_backslash(line))
+ .collect::<Vec<_>>(),
+ );
+ cell = if cell.is_table_cell() {
+ Cell::Cell(TableCell::new2(
+ &escaped,
+ Self::table_cell_options_colspan(cell.colspan()),
+ ))
+ } else {
+ Cell::Value(escaped.clone())
+ };
+ let lines = shirabe_php_shim::explode(
+ eol,
+ &shirabe_php_shim::str_replace(
+ eol,
+ &format!("<fg=default;bg=default></>{}", eol),
+ &cell.to_php_string(),
+ ),
+ );
+ for (line_key, line) in lines.into_iter().enumerate() {
+ let line_key = line_key as i64;
+ let mut line = Cell::Value(line);
+ if colspan > 1 {
+ line = Cell::Cell(TableCell::new2(
+ &line.to_php_string(),
+ Self::table_cell_options_colspan(colspan),
+ ));
+ }
+ if 0 == line_key {
+ let mut r = rows[row_key as usize].cells();
+ set_cell(&mut r, column, line);
+ rows[row_key as usize] = Row::Cells(r);
+ } else {
+ if !unmerged_rows.contains_key(&row_key)
+ || !unmerged_rows[&row_key].contains_key(&line_key)
+ {
+ let copied = self.copy_row(&rows, row_key);
+ unmerged_rows
+ .entry(row_key)
+ .or_default()
+ .insert(line_key, copied);
+ }
+ let target = unmerged_rows
+ .get_mut(&row_key)
+ .unwrap()
+ .get_mut(&line_key)
+ .unwrap();
+ set_cell(target, column, line);
+ }
+ }
+ }
+ row_key += 1;
+ }
+
+ // PHP returns a TableRows wrapping a generator that lazily yields row groups.
+ // The generator borrows $this to call fillCells(). Here the row groups are
+ // precomputed eagerly to preserve behavior, then handed to TableRows.
+ let mut row_groups: Vec<Vec<Row>> = Vec::new();
+ for (row_key, row) in rows.into_iter().enumerate() {
+ let row_key = row_key as i64;
+ let mut row_group: Vec<Row> = vec![if row.is_table_separator() {
+ row
+ } else {
+ Row::Cells(self.fill_cells(row.cells()))
+ }];
+
+ if let Some(extra) = unmerged_rows.get(&row_key) {
+ for r in extra.values() {
+ row_group.push(Row::Cells(self.fill_cells(r.clone())));
+ }
+ }
+ row_groups.push(row_group);
+ }
+
+ TableRows::from_row_groups(row_groups)
+ }
+
+ fn calculate_row_count(&mut self) -> i64 {
+ let mut merged = self.headers.clone();
+ merged.push(Row::Separator(TableSeparator::new()));
+ merged.extend(self.rows.clone());
+ let mut number_of_rows = self.build_table_rows(merged).into_row_groups().len() as i64;
+
+ if !self.headers.is_empty() {
+ number_of_rows += 1; // Add row for header separator
+ }
+
+ if !self.rows.is_empty() {
+ number_of_rows += 1; // Add row for footer separator
+ }
+
+ number_of_rows
+ }
+
+ /// fill rows that contains rowspan > 1.
+ fn fill_next_rows(&self, rows: Vec<Row>, line: i64) -> Vec<Row> {
+ let mut rows = rows;
+ let mut unmerged_rows: IndexMap<i64, IndexMap<i64, Cell>> = IndexMap::new();
+ let current = rows[line as usize].cells();
+ for (column, cell) in current.iter().enumerate() {
+ let column = column as i64;
+ let cell = cell.clone();
+ // PHP validates here that a cell is null, a TableCell, a scalar, or a __toString object.
+ // The `Cell` type makes every variant valid, so the InvalidArgumentException is dead.
+ if cell.is_table_cell() && cell.rowspan() > 1 {
+ let mut nb_lines = cell.rowspan() - 1;
+ let cell_str = cell.to_php_string();
+ let mut lines = vec![cell.clone()];
+ if shirabe_php_shim::strstr(&cell_str, "\n").is_some() {
+ let eol = if shirabe_php_shim::str_contains(&cell_str, "\r\n") {
+ "\r\n"
+ } else {
+ "\n"
+ };
+ let exploded = shirabe_php_shim::explode(
+ eol,
+ &shirabe_php_shim::str_replace(
+ eol,
+ &format!("<fg=default;bg=default>{}</>", eol),
+ &cell_str,
+ ),
+ );
+ lines = exploded.into_iter().map(Cell::Value).collect();
+ nb_lines = if (lines.len() as i64) > nb_lines {
+ shirabe_php_shim::substr_count(&cell_str, eol)
+ } else {
+ nb_lines
+ };
+
+ let mut r = rows[line as usize].cells();
+ set_cell(
+ &mut r,
+ column,
+ Cell::Cell(TableCell::new2(
+ &lines[0].to_php_string(),
+ Self::table_cell_options_colspan_style(cell.colspan(), cell.style()),
+ )),
+ );
+ rows[line as usize] = Row::Cells(r);
+ lines.remove(0);
+ }
+
+ // create a two dimensional array (rowspan x colspan)
+ for k in (line + 1)..=(line + nb_lines) {
+ unmerged_rows.entry(k).or_default();
+ }
+ for unmerged_row_key in unmerged_rows.keys().cloned().collect::<Vec<_>>() {
+ let idx = unmerged_row_key - line;
+ let value = lines
+ .get(idx as usize)
+ .cloned()
+ .unwrap_or(Cell::Value(String::new()));
+ unmerged_rows.get_mut(&unmerged_row_key).unwrap().insert(
+ column,
+ Cell::Cell(TableCell::new2(
+ &value.to_php_string(),
+ Self::table_cell_options_colspan_style(cell.colspan(), cell.style()),
+ )),
+ );
+ if nb_lines == unmerged_row_key - line {
+ break;
+ }
+ }
+ }
+ }
+
+ for (unmerged_row_key, unmerged_row) in unmerged_rows.clone() {
+ // we need to know if $unmergedRow will be merged or inserted into $rows
+ let fits = (unmerged_row_key as usize) < rows.len()
+ && matches!(rows[unmerged_row_key as usize], Row::Cells(_))
+ && (self.get_number_of_columns(&rows[unmerged_row_key as usize].cells())
+ + self.get_number_of_columns(
+ &unmerged_rows[&unmerged_row_key]
+ .values()
+ .cloned()
+ .collect::<Vec<_>>(),
+ )
+ <= self.number_of_columns.unwrap());
+ if fits {
+ let mut target = rows[unmerged_row_key as usize].cells();
+ for (cell_key, cell) in unmerged_row {
+ // insert cell into row at cellKey position
+ shirabe_php_shim::array_splice(&mut target, cell_key, Some(0), vec![cell]);
+ }
+ rows[unmerged_row_key as usize] = Row::Cells(target);
+ } else {
+ let mut row = self.copy_row(&rows, unmerged_row_key - 1);
+ for (column, cell) in &unmerged_row {
+ if cell.is_truthy() {
+ set_cell(&mut row, *column, unmerged_row[column].clone());
+ }
+ }
+ shirabe_php_shim::array_splice(
+ &mut rows,
+ unmerged_row_key,
+ Some(0),
+ vec![Row::Cells(row)],
+ );
+ }
+ }
+
+ rows
+ }
+
+ /// fill cells for a row that contains colspan > 1.
+ fn fill_cells(&self, row: Vec<Cell>) -> Vec<Cell> {
+ let mut new_row: Vec<Cell> = Vec::new();
+
+ for (column, cell) in row.iter().enumerate() {
+ let column = column as i64;
+ new_row.push(cell.clone());
+ if cell.is_table_cell() && cell.colspan() > 1 {
+ for _position in (column + 1)..=(column + cell.colspan() - 1) {
+ // insert empty value at column position
+ new_row.push(Cell::Value(String::new()));
+ }
+ }
+ }
+
+ if new_row.is_empty() { row } else { new_row }
+ }
+
+ fn copy_row(&self, rows: &[Row], line: i64) -> Vec<Cell> {
+ let mut row = rows[line as usize].cells();
+ for cell in &mut row {
+ let cell_value = cell.clone();
+ *cell = Cell::Value(String::new());
+ if cell_value.is_table_cell() {
+ *cell = Cell::Cell(TableCell::new2(
+ "",
+ Self::table_cell_options_colspan(cell_value.colspan()),
+ ));
+ }
+ }
+
+ row
+ }
+
+ /// Gets number of columns by row.
+ fn get_number_of_columns(&self, row: &[Cell]) -> i64 {
+ let mut columns = row.len() as i64;
+ for column in row {
+ columns += if column.is_table_cell() {
+ column.colspan() - 1
+ } else {
+ 0
+ };
+ }
+
+ columns
+ }
+
+ /// Gets list of columns for the given row.
+ fn get_row_columns(&self, row: &[Cell]) -> Vec<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 cell.is_table_cell() && cell.colspan() > 1 {
+ // exclude grouped columns.
+ let excluded: Vec<i64> =
+ ((cell_key + 1)..=(cell_key + cell.colspan() - 1)).collect();
+ columns.retain(|c| !excluded.contains(c));
+ }
+ }
+
+ columns
+ }
+
+ /// Calculates columns widths.
+ fn calculate_columns_width(&mut self, groups: &TableRows) {
+ let mut column = 0;
+ while column < self.number_of_columns.unwrap() {
+ let mut lengths: Vec<i64> = Vec::new();
+ for group in groups {
+ for row in group {
+ if row.is_table_separator() {
+ continue;
+ }
+
+ let mut row_arr = row.cells();
+ for i in 0..row_arr.len() {
+ let cell = row_arr[i].clone();
+ if cell.is_table_cell() {
+ let text_content = self.remove_decoration(&cell.to_php_string());
+ let text_length = Helper::width(&text_content);
+ if text_length > 0 {
+ let content_columns = shirabe_php_shim::mb_str_split(
+ &text_content,
+ (text_length as f64 / cell.colspan() as f64).ceil() as i64,
+ );
+ for (position, content) in content_columns.into_iter().enumerate() {
+ set_cell(
+ &mut row_arr,
+ i as i64 + position as i64,
+ Cell::Value(content),
+ );
+ }
+ }
+ }
+ }
+
+ lengths.push(self.get_cell_width(&row_arr, column));
+ }
+ }
+
+ self.effective_column_widths.insert(
+ column,
+ *lengths.iter().max().unwrap()
+ + Helper::width(&self.style.get_cell_row_content_format())
+ - 2,
+ );
+ column += 1;
+ }
+ }
+
+ fn get_column_separator_width(&self) -> i64 {
+ Helper::width(&shirabe_php_shim::sprintf(
+ &self.style.get_border_format(),
+ &[PhpMixed::from(self.style.get_border_chars()[3].clone())],
+ ))
+ }
+
+ fn get_cell_width(&self, row: &[Cell], column: i64) -> i64 {
+ let mut cell_width = 0;
+
+ if let Some(cell) = row.get(column as usize) {
+ cell_width = Helper::width(&self.remove_decoration(&cell.to_php_string()));
+ }
+
+ let column_width = *self.column_widths.get(&column).unwrap_or(&0);
+ cell_width = cell_width.max(column_width);
+
+ if let Some(max) = self.column_max_widths.get(&column) {
+ (*max).min(cell_width)
+ } else {
+ cell_width
+ }
+ }
+
+ /// Called after rendering to cleanup cache data.
+ fn cleanup(&mut self) {
+ self.effective_column_widths = IndexMap::new();
+ self.number_of_columns = None;
+ }
+
+ fn init_styles() -> IndexMap<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: StyleName,
+ ) -> anyhow::Result<Result<TableStyle, InvalidArgumentException>> {
+ let name = match name {
+ StyleName::Style(style) => return Ok(Ok(style)),
+ StyleName::Name(name) => name,
+ };
+
+ let styles_guard = styles().lock().unwrap();
+ if let Some(style) = styles_guard.as_ref().and_then(|s| s.get(&name)) {
+ return Ok(Ok(style.clone()));
+ }
+
+ Ok(Err(InvalidArgumentException::new(format!(
+ "Style \"{}\" is not defined.",
+ name
+ ))))
+ }
+
+ fn formatter_is_wrappable(
+ _output: &std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ ) -> bool {
+ // PHP: $this->output->getFormatter() instanceof WrappableOutputFormatterInterface.
+ // The sole OutputFormatterInterface implementor in this port is OutputFormatter, which
+ // implements WrappableOutputFormatterInterface, so the instanceof check always holds.
+ true
+ }
+
+ /// `setColumnMaxWidth` guarantees the formatter is a `WrappableOutputFormatterInterface`, and
+ /// `OutputFormatter` is the sole implementor in this port, so `instanceof` reduces to this
+ /// downcast.
+ fn format_and_wrap(&self, string: &str, width: i64) -> String {
+ let formatter = self.output.borrow().get_formatter();
+ let mut formatter = formatter.borrow_mut();
+ let formatter = formatter
+ .as_any_mut()
+ .downcast_mut::<OutputFormatter>()
+ .expect("formatter must be a WrappableOutputFormatterInterface");
+ formatter
+ .format_and_wrap(Some(string), width)
+ .unwrap()
+ .unwrap_or_default()
+ }
+
+ /// PHP `Helper::removeDecoration($this->output->getFormatter(), $string)`.
+ fn remove_decoration(&self, string: &str) -> String {
+ let formatter = self.output.borrow().get_formatter();
+ let mut formatter = formatter.borrow_mut();
+ Helper::remove_decoration(&mut *formatter, string)
+ }
+
+ fn output_is_console_section(
+ output: &std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>,
+ ) -> bool {
+ // PHP: $this->output instanceof ConsoleSectionOutput
+ let borrowed = output.borrow();
+ (*borrowed)
+ .as_any()
+ .downcast_ref::<ConsoleSectionOutput>()
+ .is_some()
+ }
+
+ fn table_cell_options_colspan(colspan: i64) -> IndexMap<String, TableCellOption> {
+ // PHP: ['colspan' => $colspan]
+ let mut options = IndexMap::new();
+ options.insert("colspan".to_string(), TableCellOption::Int(colspan));
+ options
+ }
+
+ fn table_cell_options_colspan_style(
+ colspan: i64,
+ style: Option<std::rc::Rc<TableCellStyle>>,
+ ) -> IndexMap<String, TableCellOption> {
+ // PHP: ['colspan' => $colspan, 'style' => $style]
+ let mut options = IndexMap::new();
+ options.insert("colspan".to_string(), TableCellOption::Int(colspan));
+ options.insert(
+ "style".to_string(),
+ match style {
+ Some(style) => TableCellOption::Style(style),
+ None => TableCellOption::Null,
+ },
+ );
+ options
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/table_cell.rs b/crates/shirabe-symfony-console/src/helper/table_cell.rs
new file mode 100644
index 00000000..100872ca
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/table_cell.rs
@@ -0,0 +1,99 @@
+//! ref: composer/vendor/symfony/console/Helper/TableCell.php
+
+use crate::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::helper::table_cell_style::TableCellStyle;
+use indexmap::IndexMap;
+
+/// A `TableCell` option value: an integer span, a `TableCellStyle`, or null.
+#[derive(Debug, Clone)]
+pub enum TableCellOption {
+ Int(i64),
+ Style(std::rc::Rc<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::new(format!(
+ "The TableCell does not support the following options: '{}'.",
+ diff.join("', '"),
+ )));
+ }
+
+ if let Some(style) = options.get("style")
+ && !matches!(style, TableCellOption::Style(_))
+ && !matches!(style, TableCellOption::Null)
+ {
+ return Err(InvalidArgumentException::new(
+ "The style option must be an instance of \"TableCellStyle\".".to_string(),
+ ));
+ }
+
+ for (key, option) in options {
+ this_options.insert(key, option);
+ }
+
+ Ok(Self {
+ value: value.to_string(),
+ options: this_options,
+ })
+ }
+
+ /// Two-argument constructor (`__construct(string $value, array $options)`).
+ ///
+ /// The options used by the Table helper are internally controlled, so a malformed-option
+ /// error here would be a programming bug rather than a recoverable condition.
+ pub fn new2(value: &str, options: IndexMap<String, TableCellOption>) -> Self {
+ Self::new(value, options).expect("TableCell options built internally are always valid")
+ }
+
+ /// Gets number of colspan.
+ pub fn get_colspan(&self) -> i64 {
+ match self.options["colspan"] {
+ TableCellOption::Int(colspan) => colspan,
+ _ => 0,
+ }
+ }
+
+ /// Gets number of rowspan.
+ pub fn get_rowspan(&self) -> i64 {
+ match self.options["rowspan"] {
+ TableCellOption::Int(rowspan) => rowspan,
+ _ => 0,
+ }
+ }
+
+ pub fn get_style(&self) -> Option<std::rc::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-symfony-console/src/helper/table_cell_style.rs b/crates/shirabe-symfony-console/src/helper/table_cell_style.rs
new file mode 100644
index 00000000..6f0d6b5c
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/table_cell_style.rs
@@ -0,0 +1,114 @@
+//! ref: composer/vendor/symfony/console/Helper/TableCellStyle.php
+
+use crate::exception::invalid_argument_exception::InvalidArgumentException;
+use indexmap::IndexMap;
+
+pub const DEFAULT_ALIGN: &str = "left";
+
+const TAG_OPTIONS: [&str; 3] = ["fg", "bg", "options"];
+
+/// Maps an alignment name to the corresponding `STR_PAD_*` value.
+fn align_map(align: &str) -> Option<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::new(format!(
+ "The TableCellStyle does not support the following options: '{}'.",
+ diff.join("', '"),
+ )));
+ }
+
+ if let Some(align) = options.get("align") {
+ let align = match align {
+ shirabe_php_shim::PhpMixed::String(align) => align.clone(),
+ _ => String::new(),
+ };
+ if align_map(&align).is_none() {
+ return Err(InvalidArgumentException::new(format!(
+ "Wrong align value. Value must be following: '{}'.",
+ align_map_keys().join("', '"),
+ )));
+ }
+ }
+
+ for (key, value) in options {
+ this_options.insert(key, value);
+ }
+
+ Ok(Self {
+ options: this_options,
+ })
+ }
+
+ pub fn get_options(&self) -> IndexMap<String, shirabe_php_shim::PhpMixed> {
+ self.options.clone()
+ }
+
+ /// Gets options we need for tag for example fg, bg.
+ 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-symfony-console/src/helper/table_rows.rs b/crates/shirabe-symfony-console/src/helper/table_rows.rs
new file mode 100644
index 00000000..57db5ee8
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/table_rows.rs
@@ -0,0 +1,45 @@
+//! ref: composer/vendor/symfony/console/Helper/TableRows.php
+
+use crate::helper::table::Row;
+
+/// @internal
+///
+/// In PHP this wraps a `\Closure` yielding a `\Traversable` of row groups. The generator
+/// borrows the Table to lazily call `fillCells()`. For the Rust port we precompute the row
+/// groups eagerly (see `Table::build_table_rows`) and store them here.
+#[derive(Debug)]
+pub struct TableRows {
+ row_groups: Vec<Vec<Row>>,
+}
+
+impl TableRows {
+ pub fn from_row_groups(row_groups: Vec<Vec<Row>>) -> Self {
+ Self { row_groups }
+ }
+
+ pub fn get_iterator(&self) -> std::slice::Iter<'_, Vec<Row>> {
+ self.row_groups.iter()
+ }
+
+ pub fn into_row_groups(self) -> Vec<Vec<Row>> {
+ self.row_groups
+ }
+}
+
+impl<'a> IntoIterator for &'a TableRows {
+ type Item = &'a Vec<Row>;
+ type IntoIter = std::slice::Iter<'a, Vec<Row>>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.row_groups.iter()
+ }
+}
+
+impl IntoIterator for TableRows {
+ type Item = Vec<Row>;
+ type IntoIter = std::vec::IntoIter<Vec<Row>>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.row_groups.into_iter()
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/table_separator.rs b/crates/shirabe-symfony-console/src/helper/table_separator.rs
new file mode 100644
index 00000000..99862848
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/table_separator.rs
@@ -0,0 +1,46 @@
+//! ref: composer/vendor/symfony/console/Helper/TableSeparator.php
+
+use crate::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::helper::table_cell::{TableCell, TableCellOption};
+use indexmap::IndexMap;
+
+/// Marks a row as being a separator.
+#[derive(Debug, Clone)]
+pub struct TableSeparator {
+ inner: TableCell,
+}
+
+impl TableSeparator {
+ pub fn new() -> Self {
+ Self::new1(IndexMap::new()).expect("TableSeparator default options are always valid")
+ }
+
+ pub fn new1(
+ options: IndexMap<String, TableCellOption>,
+ ) -> Result<Self, InvalidArgumentException> {
+ Ok(Self {
+ inner: TableCell::new("", options)?,
+ })
+ }
+
+ // PHP `TableSeparator extends TableCell`, so these inherited accessors remain available.
+ pub fn get_colspan(&self) -> i64 {
+ self.inner.get_colspan()
+ }
+
+ pub fn get_rowspan(&self) -> i64 {
+ self.inner.get_rowspan()
+ }
+
+ pub fn get_style(
+ &self,
+ ) -> Option<std::rc::Rc<crate::helper::table_cell_style::TableCellStyle>> {
+ self.inner.get_style()
+ }
+}
+
+impl Default for TableSeparator {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/crates/shirabe-symfony-console/src/helper/table_style.rs b/crates/shirabe-symfony-console/src/helper/table_style.rs
new file mode 100644
index 00000000..9566482f
--- /dev/null
+++ b/crates/shirabe-symfony-console/src/helper/table_style.rs
@@ -0,0 +1,289 @@
+//! ref: composer/vendor/symfony/console/Helper/TableStyle.php
+
+use crate::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::exception::logic_exception::LogicException;
+
+/// Defines the styles for a Table.
+#[derive(Debug, Clone)]
+pub struct TableStyle {
+ padding_char: String,
+ horizontal_outside_border_char: String,
+ horizontal_inside_border_char: String,
+ vertical_outside_border_char: String,
+ vertical_inside_border_char: String,
+ crossing_char: String,
+ crossing_top_right_char: String,
+ crossing_top_mid_char: String,
+ crossing_top_left_char: String,
+ crossing_mid_right_char: String,
+ crossing_bottom_right_char: String,
+ crossing_bottom_mid_char: String,
+ crossing_bottom_left_char: String,
+ crossing_mid_left_char: String,
+ crossing_top_left_bottom_char: String,
+ crossing_top_mid_bottom_char: String,
+ crossing_top_right_bottom_char: String,
+ header_title_format: String,
+ footer_title_format: String,
+ cell_header_format: String,
+ cell_row_format: String,
+ cell_row_content_format: String,
+ border_format: String,
+ pad_type: i64,
+}
+
+impl Default for TableStyle {
+ fn default() -> Self {
+ Self {
+ padding_char: " ".to_string(),
+ horizontal_outside_border_char: "-".to_string(),
+ horizontal_inside_border_char: "-".to_string(),
+ vertical_outside_border_char: "|".to_string(),
+ vertical_inside_border_char: "|".to_string(),
+ crossing_char: "+".to_string(),
+ crossing_top_right_char: "+".to_string(),
+ crossing_top_mid_char: "+".to_string(),
+ crossing_top_left_char: "+".to_string(),
+ crossing_mid_right_char: "+".to_string(),
+ crossing_bottom_right_char: "+".to_string(),
+ crossing_bottom_mid_char: "+".to_string(),
+ crossing_bottom_left_char: "+".to_string(),
+ crossing_mid_left_char: "+".to_string(),
+ crossing_top_left_bottom_char: "+".to_string(),
+ crossing_top_mid_bottom_char: "+".to_string(),
+ crossing_top_right_bottom_char: "+".to_string(),
+ header_title_format: "<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::new(
+ "The padding char must not be empty.".to_string(),
+ )));
+ }
+
+ self.padding_char = padding_char;
+
+ Ok(Ok(self))
+ }
+
+ /// Gets padding character, used for cell padding.
+ pub fn get_padding_char(&self) -> String {
+ self.padding_char.clone()
+ }
+
+ /// Sets horizontal border characters.
+ pub fn set_horizontal_border_chars(
+ &mut self,
+ outside: String,
+ inside: Option<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::new("Invalid padding type. Expected one of (STR_PAD_LEFT, STR_PAD_RIGHT, STR_PAD_BOTH)."
+ .to_string())));
+ }
+
+ self.pad_type = pad_type;
+
+ Ok(Ok(self))
+ }
+
+ /// Gets cell padding type.
+ pub fn get_pad_type(&self) -> i64 {
+ self.pad_type
+ }
+
+ pub fn get_header_title_format(&self) -> String {
+ self.header_title_format.clone()
+ }
+
+ pub fn set_header_title_format(&mut self, format: String) -> &mut Self {
+ self.header_title_format = format;
+
+ self
+ }
+
+ pub fn get_footer_title_format(&self) -> String {
+ self.footer_title_format.clone()
+ }
+
+ pub fn set_footer_title_format(&mut self, format: String) -> &mut Self {
+ self.footer_title_format = format;
+
+ self
+ }
+}