aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-external-packages/src/symfony/console/formatter
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-12 01:01:35 +0900
committernsfisis <nsfisis@gmail.com>2026-06-12 01:01:43 +0900
commit1e44f5723e4c0e0903d00a61f254901e612fe5e1 (patch)
tree9c2e1eec364bbf6418a4072ee7767b04c3df0297 /crates/shirabe-external-packages/src/symfony/console/formatter
parentddf0a624145b618c05c2ee47414545b99b685f37 (diff)
downloadphp-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.gz
php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.zst
php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.zip
feat(symfony-console): port Symfony Console and make the workspace compile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-external-packages/src/symfony/console/formatter')
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs6
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs362
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs24
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs98
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_interface.rs20
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_stack.rs108
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/formatter/wrappable_output_formatter_interface.rs11
7 files changed, 568 insertions, 61 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs
index 0431601..716d3fb 100644
--- a/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/formatter/mod.rs
@@ -1,7 +1,13 @@
pub mod output_formatter;
pub mod output_formatter_interface;
pub mod output_formatter_style;
+pub mod output_formatter_style_interface;
+pub mod output_formatter_style_stack;
+pub mod wrappable_output_formatter_interface;
pub use output_formatter::*;
pub use output_formatter_interface::*;
pub use output_formatter_style::*;
+pub use output_formatter_style_interface::*;
+pub use output_formatter_style_stack::*;
+pub use wrappable_output_formatter_interface::*;
diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs
index f64361e..413f023 100644
--- a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter.rs
@@ -1,83 +1,347 @@
+use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::symfony::console::formatter::output_formatter_interface::OutputFormatterInterface;
+use crate::symfony::console::formatter::output_formatter_style::OutputFormatterStyle;
+use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface;
+use crate::symfony::console::formatter::output_formatter_style_stack::OutputFormatterStyleStack;
+use crate::symfony::console::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface;
+use crate::symfony::string::b;
+
+/// Formatter class for console output.
#[derive(Debug)]
-pub struct OutputFormatter;
+pub struct OutputFormatter {
+ decorated: bool,
+ styles: indexmap::IndexMap<String, Box<dyn OutputFormatterStyleInterface>>,
+ style_stack: OutputFormatterStyleStack,
+}
impl OutputFormatter {
- pub fn new(
- _decorated: bool,
- _styles: indexmap::IndexMap<
- String,
- crate::symfony::console::formatter::OutputFormatterStyle,
- >,
- ) -> Self {
- todo!()
- }
+ /// Escapes "<" and ">" special chars in given text.
+ pub fn escape(text: &str) -> anyhow::Result<String> {
+ let text = shirabe_php_shim::preg_replace("/([^\\\\]|^)([<>])/", "$1\\\\$2", text)
+ .expect("preg_replace failed");
- pub fn format(&self, _message: &str) -> String {
- todo!()
+ Ok(Self::escape_trailing_backslash(&text))
}
- pub fn is_decorated(&self) -> bool {
- todo!()
+ /// Escapes trailing "\" in given text.
+ pub fn escape_trailing_backslash(text: &str) -> String {
+ let mut text = text.to_string();
+ if shirabe_php_shim::str_ends_with(&text, "\\") {
+ let len = shirabe_php_shim::strlen(&text);
+ text = shirabe_php_shim::rtrim(&text, Some("\\"));
+ text = shirabe_php_shim::str_replace("\0", "", &text);
+ text.push_str(&shirabe_php_shim::str_repeat(
+ "\0",
+ (len - shirabe_php_shim::strlen(&text)) as usize,
+ ));
+ }
+
+ text
}
- pub fn set_decorated(&mut self, _decorated: bool) {
- todo!()
+ /// Initializes console output formatter.
+ ///
+ /// `styles` is an array of "name => FormatterStyle" instances.
+ pub fn new(
+ decorated: bool,
+ styles: indexmap::IndexMap<String, Box<dyn OutputFormatterStyleInterface>>,
+ ) -> Self {
+ let mut this = Self {
+ decorated,
+ styles: indexmap::IndexMap::new(),
+ style_stack: OutputFormatterStyleStack::new(None),
+ };
+
+ this.set_style(
+ "error",
+ Box::new(OutputFormatterStyle::new(
+ Some("white"),
+ Some("red"),
+ vec![],
+ )),
+ );
+ this.set_style(
+ "info",
+ Box::new(OutputFormatterStyle::new(Some("green"), None, vec![])),
+ );
+ this.set_style(
+ "comment",
+ Box::new(OutputFormatterStyle::new(Some("yellow"), None, vec![])),
+ );
+ this.set_style(
+ "question",
+ Box::new(OutputFormatterStyle::new(
+ Some("black"),
+ Some("cyan"),
+ vec![],
+ )),
+ );
+
+ for (name, style) in styles {
+ this.set_style(&name, style);
+ }
+
+ this.style_stack = OutputFormatterStyleStack::new(None);
+
+ this
}
- pub fn escape(_text: &str) -> String {
- todo!()
+ pub fn get_style_stack(&self) -> &OutputFormatterStyleStack {
+ &self.style_stack
}
- pub fn escape_trailing_backslash(_text: &str) -> String {
- todo!()
+ /// Tries to create new style instance from string.
+ fn create_style_from_string(
+ &self,
+ string: &str,
+ ) -> anyhow::Result<Option<Box<dyn OutputFormatterStyleInterface>>> {
+ if self.styles.contains_key(string) {
+ // PHP returns the shared style instance; ownership cannot be expressed without Clone
+ // on the trait object.
+ // TODO(human-review): returning a shared style here needs an Rc/Clone strategy in
+ // Phase C.
+ return Ok(Some(todo!()));
+ }
+
+ let mut matches: Vec<Vec<String>> = vec![];
+ if shirabe_php_shim::preg_match_all_set_order(
+ "/([^=]+)=([^;]+)(;|$)/",
+ string,
+ &mut matches,
+ )? == 0
+ {
+ return Ok(None);
+ }
+
+ let mut style = OutputFormatterStyle::new(None, None, vec![]);
+ for r#match in &matches {
+ let mut r#match: Vec<String> = r#match.clone();
+ shirabe_php_shim::array_shift(&mut r#match);
+ r#match[0] = shirabe_php_shim::strtolower(&r#match[0]);
+
+ if r#match[0] == "fg" {
+ style.set_foreground(Some(&shirabe_php_shim::strtolower(&r#match[1])));
+ } else if r#match[0] == "bg" {
+ style.set_background(Some(&shirabe_php_shim::strtolower(&r#match[1])));
+ } else if r#match[0] == "href" {
+ let url = shirabe_php_shim::preg_replace("{\\\\([<>])}", "$1", &r#match[1])
+ .expect("preg_replace failed");
+ style.set_href(&url);
+ } else if r#match[0] == "options" {
+ let mut options: Vec<Vec<String>> = vec![];
+ shirabe_php_shim::preg_match_all_simple(
+ "([^,;]+)",
+ &shirabe_php_shim::strtolower(&r#match[1]),
+ &mut options,
+ )?;
+ let options = shirabe_php_shim::array_shift(&mut options).unwrap_or_default();
+ for option in &options {
+ style.set_option(option);
+ }
+ } else {
+ return Ok(None);
+ }
+ }
+
+ Ok(Some(Box::new(style)))
}
- pub fn set_style(
+ /// Applies current style from stack to text, if must be applied.
+ fn apply_current_style(
&mut self,
- _name: &str,
- _style: crate::symfony::console::formatter::OutputFormatterStyle,
- ) {
- todo!()
- }
+ text: &str,
+ current: &str,
+ width: i64,
+ current_line_length: &mut i64,
+ ) -> String {
+ if text.is_empty() {
+ return String::new();
+ }
- pub fn has_style(&self, _name: &str) -> bool {
- todo!()
+ if width == 0 {
+ return if self.is_decorated() {
+ self.style_stack.get_current_mut().apply(text)
+ } else {
+ text.to_string()
+ };
+ }
+
+ let mut text = text.to_string();
+
+ if *current_line_length == 0 && !current.is_empty() {
+ text = shirabe_php_shim::ltrim(&text, None);
+ }
+
+ let prefix;
+ if *current_line_length != 0 {
+ let i = width - *current_line_length;
+ prefix = format!("{}\n", shirabe_php_shim::substr(&text, 0, Some(i)));
+ text = shirabe_php_shim::substr(&text, i, None);
+ } else {
+ prefix = String::new();
+ }
+
+ let mut matches: Vec<Option<String>> = vec![];
+ shirabe_php_shim::preg_match("~(\\n)$~", &text, &mut matches);
+ text = format!("{}{}", prefix, self.add_line_breaks(&text, width));
+ let trailing = matches.get(1).and_then(|m| m.clone()).unwrap_or_default();
+ text = format!("{}{}", shirabe_php_shim::rtrim(&text, Some("\n")), trailing);
+
+ if *current_line_length == 0
+ && !current.is_empty()
+ && shirabe_php_shim::substr(current, -1, None) != "\n"
+ {
+ text = format!("\n{text}");
+ }
+
+ let mut lines = shirabe_php_shim::explode("\n", &text);
+
+ for line in &lines {
+ *current_line_length += shirabe_php_shim::strlen(line);
+ if width <= *current_line_length {
+ *current_line_length = 0;
+ }
+ }
+
+ if self.is_decorated() {
+ for line in lines.iter_mut() {
+ *line = self.style_stack.get_current_mut().apply(line);
+ }
+ }
+
+ shirabe_php_shim::implode("\n", &lines)
}
- pub fn get_style(
- &self,
- _name: &str,
- ) -> crate::symfony::console::formatter::OutputFormatterStyle {
- todo!()
+ fn add_line_breaks(&self, text: &str, width: i64) -> String {
+ let encoding = shirabe_php_shim::mb_detect_encoding(text, None, true)
+ .filter(|s| !s.is_empty())
+ .unwrap_or_else(|| "UTF-8".to_string());
+
+ b(text)
+ .to_code_point_string(&encoding)
+ .wordwrap(width, "\n", true)
+ .to_byte_string(&encoding)
}
}
-impl crate::symfony::console::formatter::OutputFormatterInterface for OutputFormatter {
- fn is_decorated(&self) -> bool {
- self.is_decorated()
+impl OutputFormatterInterface for OutputFormatter {
+ fn set_decorated(&mut self, decorated: bool) {
+ self.decorated = decorated;
}
- fn set_decorated(&mut self, decorated: bool) {
- self.set_decorated(decorated)
+ fn is_decorated(&self) -> bool {
+ self.decorated
}
- fn set_style(
- &mut self,
- name: &str,
- style: crate::symfony::console::formatter::OutputFormatterStyle,
- ) {
- self.set_style(name, style)
+ fn set_style(&mut self, name: &str, style: Box<dyn OutputFormatterStyleInterface>) {
+ self.styles
+ .insert(shirabe_php_shim::strtolower(name), style);
}
fn has_style(&self, name: &str) -> bool {
- self.has_style(name)
+ self.styles
+ .contains_key(&shirabe_php_shim::strtolower(name))
}
- fn get_style(&self, name: &str) -> crate::symfony::console::formatter::OutputFormatterStyle {
- self.get_style(name)
+ fn get_style(&self, name: &str) -> anyhow::Result<Box<dyn OutputFormatterStyleInterface>> {
+ if !self.has_style(name) {
+ return Err(anyhow::anyhow!(InvalidArgumentException(
+ shirabe_php_shim::InvalidArgumentException {
+ message: shirabe_php_shim::sprintf(
+ "Undefined style: \"%s\".",
+ &[shirabe_php_shim::PhpMixed::String(name.to_string())],
+ ),
+ code: 0,
+ },
+ )));
+ }
+
+ // PHP returns the shared style instance; ownership cannot be expressed without Clone on
+ // the trait object.
+ // TODO(human-review): returning a shared style here needs an Rc/Clone strategy in Phase C.
+ let _ = &self.styles[&shirabe_php_shim::strtolower(name)];
+ todo!()
}
- fn format(&self, message: &str) -> String {
- self.format(message)
+ fn format(&mut self, message: Option<&str>) -> anyhow::Result<Option<String>> {
+ self.format_and_wrap(message, 0)
+ }
+}
+
+impl WrappableOutputFormatterInterface for OutputFormatter {
+ fn format_and_wrap(
+ &mut self,
+ message: Option<&str>,
+ width: i64,
+ ) -> anyhow::Result<Option<String>> {
+ let message = match message {
+ None => return Ok(Some(String::new())),
+ Some(message) => message,
+ };
+
+ let mut offset: i64 = 0;
+ let mut output = String::new();
+ let open_tag_regex = "[a-z](?:[^\\\\<>]*+ | \\\\.)*";
+ let close_tag_regex = "[a-z][^<>]*+";
+ let mut current_line_length: i64 = 0;
+ let mut matches: shirabe_php_shim::PregOffsetCaptureMatches = Default::default();
+ shirabe_php_shim::preg_match_all_offset_capture(
+ &format!("#<(({open_tag_regex}) | /({close_tag_regex})?)>#ix"),
+ message,
+ &mut matches,
+ )?;
+ let count = matches.group(0).len();
+ for i in 0..count {
+ let (text, pos) = matches.group(0)[i].clone();
+ let pos = pos as i64;
+
+ if pos != 0 && shirabe_php_shim::byte_at(message, (pos - 1) as usize) == b'\\' {
+ continue;
+ }
+
+ // add the text up to the next tag
+ let segment = shirabe_php_shim::substr(message, offset, Some(pos - offset));
+ let applied =
+ self.apply_current_style(&segment, &output, width, &mut current_line_length);
+ output.push_str(&applied);
+ offset = pos + shirabe_php_shim::strlen(&text);
+
+ // opening tag?
+ let open = shirabe_php_shim::byte_at(&text, 1) != b'/';
+ let tag = if open {
+ matches.group(1)[i].0.clone()
+ } else {
+ matches
+ .group(3)
+ .get(i)
+ .map(|m| m.0.clone())
+ .unwrap_or_default()
+ };
+
+ if !open && tag.is_empty() {
+ // </>
+ self.style_stack.pop(None)?.ok();
+ } else if let Some(style) = self.create_style_from_string(&tag)? {
+ if open {
+ self.style_stack.push(style);
+ } else {
+ self.style_stack.pop(Some(style))?.ok();
+ }
+ } else {
+ let applied =
+ self.apply_current_style(&text, &output, width, &mut current_line_length);
+ output.push_str(&applied);
+ }
+ }
+
+ let segment = shirabe_php_shim::substr(message, offset, None);
+ let applied = self.apply_current_style(&segment, &output, width, &mut current_line_length);
+ output.push_str(&applied);
+
+ let mut pairs = indexmap::IndexMap::new();
+ pairs.insert("\0".to_string(), "\\".to_string());
+ pairs.insert("\\<".to_string(), "<".to_string());
+ pairs.insert("\\>".to_string(), ">".to_string());
+ Ok(Some(shirabe_php_shim::strtr_array(&output, &pairs)))
}
}
diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs
index 7f6e9b0..03d728c 100644
--- a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_interface.rs
@@ -1,10 +1,24 @@
-use crate::symfony::console::formatter::OutputFormatterStyle;
+use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface;
+/// Formatter interface for console output.
pub trait OutputFormatterInterface {
- fn is_decorated(&self) -> bool;
+ /// Sets the decorated flag.
fn set_decorated(&mut self, decorated: bool);
- fn set_style(&mut self, name: &str, style: OutputFormatterStyle);
+
+ /// Whether the output will decorate messages.
+ fn is_decorated(&self) -> bool;
+
+ /// Sets a new style.
+ fn set_style(&mut self, name: &str, style: Box<dyn OutputFormatterStyleInterface>);
+
+ /// Checks if output formatter has style with specified name.
fn has_style(&self, name: &str) -> bool;
- fn get_style(&self, name: &str) -> OutputFormatterStyle;
- fn format(&self, message: &str) -> String;
+
+ /// Gets style options from style with specified name.
+ ///
+ /// Throws InvalidArgumentException when style isn't defined.
+ fn get_style(&self, name: &str) -> anyhow::Result<Box<dyn OutputFormatterStyleInterface>>;
+
+ /// Formats a message according to the given styles.
+ fn format(&mut self, message: Option<&str>) -> anyhow::Result<Option<String>>;
}
diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs
index 6299157..835bcd6 100644
--- a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style.rs
@@ -1,12 +1,96 @@
+use crate::symfony::console::color::Color;
+use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface;
+
+/// Formatter style class for defining styles.
#[derive(Debug)]
-pub struct OutputFormatterStyle;
+pub struct OutputFormatterStyle {
+ color: Color,
+ foreground: String,
+ background: String,
+ options: Vec<String>,
+ href: Option<String>,
+ handles_href_gracefully: Option<bool>,
+}
impl OutputFormatterStyle {
- pub fn new(
- _foreground: Option<&str>,
- _background: Option<&str>,
- _options: Option<Vec<String>>,
- ) -> Self {
- todo!()
+ /// Initializes output formatter style.
+ pub fn new(foreground: Option<&str>, background: Option<&str>, options: Vec<String>) -> Self {
+ let foreground = foreground
+ .filter(|s| !s.is_empty())
+ .unwrap_or("")
+ .to_string();
+ let background = background
+ .filter(|s| !s.is_empty())
+ .unwrap_or("")
+ .to_string();
+ let color = Color::new(&foreground, &background, &options.clone()).unwrap();
+ Self {
+ color,
+ foreground,
+ background,
+ options,
+ href: None,
+ handles_href_gracefully: None,
+ }
+ }
+
+ pub fn set_href(&mut self, url: &str) {
+ self.href = Some(url.to_string());
+ }
+}
+
+impl OutputFormatterStyleInterface for OutputFormatterStyle {
+ fn set_foreground(&mut self, color: Option<&str>) {
+ self.foreground = color.filter(|s| !s.is_empty()).unwrap_or("").to_string();
+ self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap();
+ }
+
+ fn set_background(&mut self, color: Option<&str>) {
+ self.background = color.filter(|s| !s.is_empty()).unwrap_or("").to_string();
+ self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap();
+ }
+
+ fn set_option(&mut self, option: &str) {
+ self.options.push(option.to_string());
+ self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap();
+ }
+
+ fn unset_option(&mut self, option: &str) {
+ let pos = shirabe_php_shim::array_search_in_vec(option, &self.options);
+ if let Some(pos) = pos {
+ self.options.remove(pos);
+ }
+
+ self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap();
+ }
+
+ fn set_options(&mut self, options: Vec<String>) {
+ self.options = options;
+ self.color = Color::new(&self.foreground, &self.background, &self.options.clone()).unwrap();
+ }
+
+ fn apply(&mut self, text: &str) -> String {
+ let mut text = text.to_string();
+
+ if self.handles_href_gracefully.is_none() {
+ self.handles_href_gracefully = Some(
+ shirabe_php_shim::getenv("TERMINAL_EMULATOR").as_deref()
+ != Some("JetBrains-JediTerm")
+ && (shirabe_php_shim::getenv("KONSOLE_VERSION").is_none_or(|v| v.is_empty())
+ || shirabe_php_shim::getenv("KONSOLE_VERSION")
+ .map(|v| v.parse::<i64>().unwrap_or(0))
+ .unwrap_or(0)
+ > 201100)
+ && !shirabe_php_shim::server_contains_key("IDEA_INITIAL_DIRECTORY"),
+ );
+ }
+
+ if let Some(href) = &self.href {
+ if self.handles_href_gracefully == Some(true) {
+ text = format!("\x1b]8;;{href}\x1b\\{text}\x1b]8;;\x1b\\");
+ }
+ }
+
+ self.color.apply(&text)
}
}
diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_interface.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_interface.rs
new file mode 100644
index 0000000..c9a2648
--- /dev/null
+++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_interface.rs
@@ -0,0 +1,20 @@
+/// Formatter style interface for defining styles.
+pub trait OutputFormatterStyleInterface: std::fmt::Debug {
+ /// Sets style foreground color.
+ fn set_foreground(&mut self, color: Option<&str>);
+
+ /// Sets style background color.
+ fn set_background(&mut self, color: Option<&str>);
+
+ /// Sets some specific style option.
+ fn set_option(&mut self, option: &str);
+
+ /// Unsets some specific style option.
+ fn unset_option(&mut self, option: &str);
+
+ /// Sets multiple style options at once.
+ fn set_options(&mut self, options: Vec<String>);
+
+ /// Applies the style to a given text.
+ fn apply(&mut self, text: &str) -> String;
+}
diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_stack.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_stack.rs
new file mode 100644
index 0000000..2b290db
--- /dev/null
+++ b/crates/shirabe-external-packages/src/symfony/console/formatter/output_formatter_style_stack.rs
@@ -0,0 +1,108 @@
+use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::symfony::console::formatter::output_formatter_style::OutputFormatterStyle;
+use crate::symfony::console::formatter::output_formatter_style_interface::OutputFormatterStyleInterface;
+use crate::symfony::contracts::service::reset_interface::ResetInterface;
+
+#[derive(Debug)]
+pub struct OutputFormatterStyleStack {
+ styles: Vec<Box<dyn OutputFormatterStyleInterface>>,
+ empty_style: Box<dyn OutputFormatterStyleInterface>,
+}
+
+impl OutputFormatterStyleStack {
+ pub fn new(empty_style: Option<Box<dyn OutputFormatterStyleInterface>>) -> Self {
+ let empty_style =
+ empty_style.unwrap_or_else(|| Box::new(OutputFormatterStyle::new(None, None, vec![])));
+ let mut this = Self {
+ styles: vec![],
+ empty_style,
+ };
+ this.reset();
+ this
+ }
+
+ /// Pushes a style in the stack.
+ pub fn push(&mut self, style: Box<dyn OutputFormatterStyleInterface>) {
+ self.styles.push(style);
+ }
+
+ /// Pops a style from the stack.
+ ///
+ /// Throws InvalidArgumentException when style tags incorrectly nested.
+ pub fn pop(
+ &mut self,
+ mut style: Option<Box<dyn OutputFormatterStyleInterface>>,
+ ) -> anyhow::Result<Result<Box<dyn OutputFormatterStyleInterface>, InvalidArgumentException>>
+ {
+ if self.styles.is_empty() {
+ // Returns the shared empty style; ownership cannot be expressed without Clone on the
+ // trait object.
+ // TODO(human-review): empty-style branch needs a Clone/Rc strategy in Phase C.
+ return Ok(Ok(todo!()));
+ }
+
+ let style = match style.as_mut() {
+ None => {
+ return Ok(Ok(shirabe_php_shim::array_pop(&mut self.styles).unwrap()));
+ }
+ Some(style) => style,
+ };
+
+ for index in (0..self.styles.len()).rev() {
+ if style.apply("") == self.styles[index].apply("") {
+ // PHP: array_slice($this->styles, 0, $index) keeps elements before $index,
+ // dropping the matched element and everything after it.
+ let stacked_style = self.styles.remove(index);
+ self.styles.truncate(index);
+
+ return Ok(Ok(stacked_style));
+ }
+ }
+
+ Ok(Err(InvalidArgumentException(
+ shirabe_php_shim::InvalidArgumentException {
+ message: "Incorrectly nested style tag found.".to_string(),
+ code: 0,
+ },
+ )))
+ }
+
+ /// Computes current style with stacks top codes.
+ pub fn get_current(&self) -> &dyn OutputFormatterStyleInterface {
+ if self.styles.is_empty() {
+ return self.empty_style.as_ref();
+ }
+
+ self.styles[self.styles.len() - 1].as_ref()
+ }
+
+ /// Mutable variant of `get_current`, needed because `apply` lazily mutates style state.
+ pub fn get_current_mut(&mut self) -> &mut dyn OutputFormatterStyleInterface {
+ if self.styles.is_empty() {
+ return self.empty_style.as_mut();
+ }
+
+ let last = self.styles.len() - 1;
+ self.styles[last].as_mut()
+ }
+
+ pub fn set_empty_style(
+ &mut self,
+ empty_style: Box<dyn OutputFormatterStyleInterface>,
+ ) -> &mut Self {
+ self.empty_style = empty_style;
+
+ self
+ }
+
+ pub fn get_empty_style(&self) -> &dyn OutputFormatterStyleInterface {
+ self.empty_style.as_ref()
+ }
+}
+
+impl ResetInterface for OutputFormatterStyleStack {
+ /// Resets stack (ie. empty internal arrays).
+ fn reset(&mut self) {
+ self.styles = vec![];
+ }
+}
diff --git a/crates/shirabe-external-packages/src/symfony/console/formatter/wrappable_output_formatter_interface.rs b/crates/shirabe-external-packages/src/symfony/console/formatter/wrappable_output_formatter_interface.rs
new file mode 100644
index 0000000..be8b466
--- /dev/null
+++ b/crates/shirabe-external-packages/src/symfony/console/formatter/wrappable_output_formatter_interface.rs
@@ -0,0 +1,11 @@
+use crate::symfony::console::formatter::output_formatter_interface::OutputFormatterInterface;
+
+/// Formatter interface for console output that supports word wrapping.
+pub trait WrappableOutputFormatterInterface: OutputFormatterInterface {
+ /// Formats a message according to the given styles, wrapping at `width` (0 means no wrapping).
+ fn format_and_wrap(
+ &mut self,
+ message: Option<&str>,
+ width: i64,
+ ) -> anyhow::Result<Option<String>>;
+}