diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-12 01:01:35 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-12 01:01:43 +0900 |
| commit | 1e44f5723e4c0e0903d00a61f254901e612fe5e1 (patch) | |
| tree | 9c2e1eec364bbf6418a4072ee7767b04c3df0297 /crates/shirabe-external-packages/src/symfony/console/input | |
| parent | ddf0a624145b618c05c2ee47414545b99b685f37 (diff) | |
| download | php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.gz php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.tar.zst php-shirabe-1e44f5723e4c0e0903d00a61f254901e612fe5e1.zip | |
feat(symfony-console): port Symfony Console and make the workspace compile
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-external-packages/src/symfony/console/input')
11 files changed, 2145 insertions, 128 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs new file mode 100644 index 0000000..e9cb8d3 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs @@ -0,0 +1,643 @@ +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::input::input::Input; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::streamable_input_interface::StreamableInputInterface; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// ArgvInput represents an input coming from the CLI arguments. +/// +/// Usage: +/// +/// $input = new ArgvInput(); +/// +/// By default, the `$_SERVER['argv']` array is used for the input values. +/// +/// This can be overridden by explicitly passing the input values in the constructor: +/// +/// $input = new ArgvInput($_SERVER['argv']); +/// +/// If you pass it yourself, don't forget that the first element of the array +/// is the name of the running application. +/// +/// When passing an argument to the constructor, be sure that it respects +/// the same rules as the argv one. It's almost always better to use the +/// `StringInput` when you want to provide your own input. +#[derive(Debug)] +pub struct ArgvInput { + pub(crate) inner: Input, + tokens: Vec<String>, + parsed: Vec<String>, +} + +impl ArgvInput { + pub fn new( + argv: Option<Vec<String>>, + definition: Option<InputDefinition>, + ) -> anyhow::Result<Self> { + // $argv = $argv ?? $_SERVER['argv'] ?? []; + let mut argv = match argv { + Some(argv) => argv, + None => std::env::args().collect(), + }; + + // strip the application name + if !argv.is_empty() { + argv.remove(0); + } + + let mut input = ArgvInput { + inner: Input::new(None)?, + tokens: argv, + parsed: vec![], + }; + + // parent::__construct($definition) + match definition { + None => {} + Some(definition) => { + input.bind(&definition)?; + input.inner.validate()?; + } + } + + Ok(input) + } + + pub(crate) fn set_tokens(&mut self, tokens: Vec<String>) { + self.tokens = tokens; + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.inner.arguments = IndexMap::new(); + self.inner.options = IndexMap::new(); + self.inner.definition = definition.clone(); + + self.parse()?; + + Ok(()) + } + + fn parse(&mut self) -> anyhow::Result<()> { + let mut parse_options = true; + self.parsed = self.tokens.clone(); + while !self.parsed.is_empty() { + let token = self.parsed.remove(0); + parse_options = self.parse_token(&token, parse_options)?; + } + Ok(()) + } + + pub(crate) fn parse_token(&mut self, token: &str, parse_options: bool) -> anyhow::Result<bool> { + if parse_options && token == "" { + self.parse_argument(token)?; + } else if parse_options && token == "--" { + return Ok(false); + } else if parse_options && shirabe_php_shim::str_starts_with(token, "--") { + self.parse_long_option(token)?; + } else if parse_options && token.as_bytes().first() == Some(&b'-') && token != "-" { + self.parse_short_option(token)?; + } else { + self.parse_argument(token)?; + } + + Ok(parse_options) + } + + /// Parses a short option. + fn parse_short_option(&mut self, token: &str) -> anyhow::Result<()> { + let name = shirabe_php_shim::substr(token, 1, None); + + if shirabe_php_shim::strlen(&name) > 1 { + let first = shirabe_php_shim::substr(&name, 0, Some(1)); + if self.inner.definition.has_shortcut(&first) + && self + .inner + .definition + .get_option_for_shortcut(&first)? + .accept_value() + { + // an option with a value (with no space) + self.add_short_option( + &first, + PhpMixed::String(shirabe_php_shim::substr(&name, 1, None)), + )?; + } else { + self.parse_short_option_set(&name)?; + } + } else { + self.add_short_option(&name, PhpMixed::Null)?; + } + + Ok(()) + } + + /// Parses a short option set. + fn parse_short_option_set(&mut self, name: &str) -> anyhow::Result<()> { + let len = shirabe_php_shim::strlen(name); + let mut i = 0; + while i < len { + let name_i = shirabe_php_shim::substr(name, i, Some(1)); + if !self.inner.definition.has_shortcut(&name_i) { + let encoding = shirabe_php_shim::mb_detect_encoding(name, None, true); + let bad = match encoding { + None => name_i.clone(), + Some(encoding) => { + shirabe_php_shim::mb_substr(name, i, Some(1), Some(&encoding)) + } + }; + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(bad)], + ), + code: 0, + }) + .into()); + } + + let option = self.inner.definition.get_option_for_shortcut(&name_i)?; + if option.accept_value() { + let value = if i == len - 1 { + PhpMixed::Null + } else { + PhpMixed::String(shirabe_php_shim::substr(name, i + 1, None)) + }; + self.add_long_option(&option.get_name().to_string(), value)?; + + break; + } else { + self.add_long_option(&option.get_name().to_string(), PhpMixed::Null)?; + } + i += 1; + } + + Ok(()) + } + + /// Parses a long option. + fn parse_long_option(&mut self, token: &str) -> anyhow::Result<()> { + let name = shirabe_php_shim::substr(token, 2, None); + + match shirabe_php_shim::strpos(&name, "=") { + Some(pos) => { + let pos = pos as i64; + let value = shirabe_php_shim::substr(&name, pos + 1, None); + if value == "" { + self.parsed.insert(0, value.clone()); + } + self.add_long_option( + &shirabe_php_shim::substr(&name, 0, Some(pos)), + PhpMixed::String(value), + )?; + } + None => { + self.add_long_option(&name, PhpMixed::Null)?; + } + } + + Ok(()) + } + + /// Parses an argument. + fn parse_argument(&mut self, token: &str) -> anyhow::Result<()> { + let c = self.inner.arguments.len() as i64; + + // if input is expecting another argument, add it + if self.inner.definition.has_argument(&PhpMixed::Int(c)) { + let arg = self.inner.definition.get_argument(&PhpMixed::Int(c))?; + let value = if arg.is_array() { + PhpMixed::List(vec![Box::new(PhpMixed::String(token.to_string()))]) + } else { + PhpMixed::String(token.to_string()) + }; + self.inner + .arguments + .insert(arg.get_name().to_string(), value); + + // if last argument isArray(), append token to last argument + } else if self.inner.definition.has_argument(&PhpMixed::Int(c - 1)) + && self + .inner + .definition + .get_argument(&PhpMixed::Int(c - 1))? + .is_array() + { + let arg = self.inner.definition.get_argument(&PhpMixed::Int(c - 1))?; + if let Some(PhpMixed::List(list)) = self.inner.arguments.get_mut(arg.get_name()) { + list.push(Box::new(PhpMixed::String(token.to_string()))); + } + + // unexpected argument + } else { + let mut all = self.inner.definition.get_arguments().clone(); + let mut symfony_command_name: Option<PhpMixed> = None; + let first_key = all.keys().next().cloned(); + if let Some(key) = &first_key { + let input_argument = &all[key]; + if input_argument.get_name() == "command" { + symfony_command_name = match self.inner.arguments.get("command") { + Some(v) => Some(v.clone()), + None => None, + }; + all.shift_remove(key); + } + } + + let message = if all.len() > 0 { + let names: Vec<String> = all.keys().cloned().collect(); + match &symfony_command_name { + Some(symfony_command_name) + if !matches!(symfony_command_name, PhpMixed::Null) => + { + shirabe_php_shim::sprintf( + "Too many arguments to \"%s\" command, expected arguments \"%s\".", + &[ + symfony_command_name.clone(), + PhpMixed::String(shirabe_php_shim::implode("\" \"", &names)), + ], + ) + } + _ => shirabe_php_shim::sprintf( + "Too many arguments, expected arguments \"%s\".", + &[PhpMixed::String(shirabe_php_shim::implode("\" \"", &names))], + ), + } + } else if symfony_command_name + .as_ref() + .map(|n| !matches!(n, PhpMixed::Null)) + .unwrap_or(false) + { + shirabe_php_shim::sprintf( + "No arguments expected for \"%s\" command, got \"%s\".", + &[ + symfony_command_name.clone().unwrap(), + PhpMixed::String(token.to_string()), + ], + ) + } else { + shirabe_php_shim::sprintf( + "No arguments expected, got \"%s\".", + &[PhpMixed::String(token.to_string())], + ) + }; + + return Err( + RuntimeException(shirabe_php_shim::RuntimeException { message, code: 0 }).into(), + ); + } + + Ok(()) + } + + /// Adds a short option value. + fn add_short_option(&mut self, shortcut: &str, value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_shortcut(shortcut) { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(shortcut.to_string())], + ), + code: 0, + }) + .into()); + } + + self.add_long_option( + &self + .inner + .definition + .get_option_for_shortcut(shortcut)? + .get_name() + .to_string(), + value, + ) + } + + /// Adds a long option value. + fn add_long_option(&mut self, name: &str, mut value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_option(name) { + if !self.inner.definition.has_negation(name) { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + + let option_name = self.inner.definition.negation_to_name(name)?; + if !matches!(value, PhpMixed::Null) { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not accept a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + self.inner + .options + .insert(option_name, PhpMixed::Bool(false)); + + return Ok(()); + } + + let option = self.inner.definition.get_option(name)?; + + if !matches!(value, PhpMixed::Null) && !option.accept_value() { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not accept a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + + // in_array($value, ['', null], true) + let value_is_empty_or_null = matches!(&value, PhpMixed::String(s) if s.is_empty()) + || matches!(value, PhpMixed::Null); + if value_is_empty_or_null && option.accept_value() && !self.parsed.is_empty() { + // if option accepts an optional or mandatory argument + // let's see if there is one provided + let next = self.parsed.remove(0); + // (isset($next[0]) && '-' !== $next[0]) || in_array($next, ['', null], true) + let next_first = next.as_bytes().first().copied(); + if (next_first.is_some() && next_first != Some(b'-')) || next.is_empty() { + value = PhpMixed::String(next); + } else { + self.parsed.insert(0, next); + } + } + + if matches!(value, PhpMixed::Null) { + if option.is_value_required() { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option requires a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into()); + } + + if !option.is_array() && !option.is_value_optional() { + value = PhpMixed::Bool(true); + } + } + + if option.is_array() { + match self.inner.options.get_mut(name) { + Some(PhpMixed::List(list)) => { + list.push(Box::new(value)); + } + _ => { + self.inner + .options + .insert(name.to_string(), PhpMixed::List(vec![Box::new(value)])); + } + } + } else { + self.inner.options.insert(name.to_string(), value); + } + + Ok(()) + } + + pub fn get_first_argument(&self) -> Option<String> { + let mut is_option = false; + for (i, token) in self.tokens.iter().enumerate() { + if !token.is_empty() && token.as_bytes()[0] == b'-' { + if shirabe_php_shim::str_contains(token, "=") || self.tokens.get(i + 1).is_none() { + continue; + } + + // If it's a long option, consider that everything after "--" is the option name. + // Otherwise, use the last char (if it's a short option set, only the last one can take a value with space separator) + let mut name = if token.as_bytes().get(1) == Some(&b'-') { + shirabe_php_shim::substr(token, 2, None) + } else { + shirabe_php_shim::substr(token, -1, None) + }; + if !self.inner.options.contains_key(&name) + && !self.inner.definition.has_shortcut(&name) + { + // noop + } else { + if !self.inner.options.contains_key(&name) { + if let Ok(resolved) = self.inner.definition.shortcut_to_name(&name) { + name = resolved; + } + } + if let Some(option_value) = self.inner.options.get(&name) { + if self.tokens.get(i + 1).map(|t| t.as_str()) == option_value.as_string() { + is_option = true; + } + } + } + + continue; + } + + if is_option { + is_option = false; + continue; + } + + return Some(token.clone()); + } + + None + } + + pub fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + let values = to_array(values); + + for token in &self.tokens { + if only_params && token == "--" { + return false; + } + for value in &values { + // Options with values: + // For long options, test for '--option=' at beginning + // For short options, test for '-o' at beginning + let leading = if shirabe_php_shim::str_starts_with(value, "--") { + format!("{}=", value) + } else { + value.clone() + }; + if token == value + || (!leading.is_empty() && shirabe_php_shim::str_starts_with(token, &leading)) + { + return true; + } + } + } + + false + } + + pub fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + let values = to_array(values); + let mut tokens = self.tokens.clone(); + + while !tokens.is_empty() { + let token = tokens.remove(0); + if only_params && token == "--" { + return default; + } + + for value in &values { + if &token == value { + return match tokens.first() { + Some(_) => PhpMixed::String(tokens.remove(0)), + None => PhpMixed::Null, + }; + } + // Options with values: + // For long options, test for '--option=' at beginning + // For short options, test for '-o' at beginning + let leading = if shirabe_php_shim::str_starts_with(value, "--") { + format!("{}=", value) + } else { + value.clone() + }; + if !leading.is_empty() && shirabe_php_shim::str_starts_with(&token, &leading) { + return PhpMixed::String(shirabe_php_shim::substr( + &token, + shirabe_php_shim::strlen(&leading), + None, + )); + } + } + } + + default + } + + /// Returns a stringified representation of the args passed to the command. + pub fn to_string(&self) -> String { + let tokens: Vec<String> = self + .tokens + .iter() + .map(|token| { + if let Some(m) = shirabe_php_shim::preg_match_groups("{^(-[^=]+=)(.+)}", token) { + return format!("{}{}", m[1], self.inner.escape_token(&m[2])); + } + + if !token.is_empty() && token.as_bytes()[0] != b'-' { + return self.inner.escape_token(token); + } + + token.clone() + }) + .collect(); + + shirabe_php_shim::implode(" ", &tokens) + } +} + +impl InputInterface for ArgvInput { + fn get_first_argument(&self) -> Option<String> { + ArgvInput::get_first_argument(self) + } + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + ArgvInput::has_parameter_option(self, values, only_params) + } + + fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + ArgvInput::get_parameter_option(self, values, default, only_params) + } + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + ArgvInput::bind(self, definition) + } + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() + } + + fn get_arguments(&self) -> IndexMap<String, PhpMixed> { + self.inner.get_arguments() + } + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_argument(name) + } + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_argument(name, value) + } + + fn has_argument(&self, name: &str) -> bool { + self.inner.has_argument(name) + } + + fn get_options(&self) -> IndexMap<String, PhpMixed> { + self.inner.get_options() + } + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_option(name) + } + + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_option(name, value) + } + + fn has_option(&self, name: &str) -> bool { + self.inner.has_option(name) + } + + fn is_interactive(&self) -> bool { + self.inner.is_interactive() + } + + fn set_interactive(&mut self, interactive: bool) { + self.inner.set_interactive(interactive) + } +} + +impl StreamableInputInterface for ArgvInput { + fn set_stream(&mut self, stream: PhpMixed) { + self.inner.set_stream(stream) + } + + fn get_stream(&self) -> Option<PhpMixed> { + self.inner.get_stream() + } +} + +/// PHP `(array) $values` cast: a string becomes a single-element array. +fn to_array(values: PhpMixed) -> Vec<String> { + match values { + PhpMixed::List(list) => list + .into_iter() + .map(|v| shirabe_php_shim::php_to_string(&v)) + .collect(), + PhpMixed::Array(array) => array + .into_iter() + .map(|(_, v)| shirabe_php_shim::php_to_string(&v)) + .collect(), + PhpMixed::Null => vec![], + other => vec![shirabe_php_shim::php_to_string(&other)], + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs index 0c68cf2..17c8aa8 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs @@ -1,69 +1,378 @@ -use crate::symfony::console::input::InputDefinition; -use crate::symfony::console::input::InputInterface; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::invalid_option_exception::InvalidOptionException; +use crate::symfony::console::input::input::Input; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +/// ArrayInput represents an input provided as an array. +/// +/// Usage: +/// +/// $input = new ArrayInput(['command' => 'foo:bar', 'foo' => 'bar', '--bar' => 'foobar']); +/// +/// PHP arrays can mix integer and string keys; `parameters` preserves both the +/// key type (`PhpMixed::Int` / `PhpMixed::String`) and the insertion order. #[derive(Debug)] -pub struct ArrayInput; +pub struct ArrayInput { + pub(crate) inner: Input, + parameters: Vec<(PhpMixed, PhpMixed)>, +} impl ArrayInput { pub fn new( - _parameters: IndexMap<String, PhpMixed>, - _definition: Option<InputDefinition>, - ) -> Self { - todo!() + parameters: Vec<(PhpMixed, PhpMixed)>, + definition: Option<InputDefinition>, + ) -> anyhow::Result<Self> { + let mut array_input = ArrayInput { + inner: Input::new(None)?, + parameters, + }; + + // parent::__construct($definition) + match definition { + None => {} + Some(definition) => { + array_input.bind(&definition)?; + array_input.inner.validate()?; + } + } + + Ok(array_input) + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.inner.arguments = IndexMap::new(); + self.inner.options = IndexMap::new(); + self.inner.definition = definition.clone(); + + self.parse()?; + + Ok(()) + } + + pub fn get_first_argument(&self) -> Option<PhpMixed> { + for (param, value) in &self.parameters { + // $param && \is_string($param) && '-' === $param[0] + if let PhpMixed::String(param) = param { + if !param.is_empty() && param.as_bytes()[0] == b'-' { + continue; + } + } + + return Some(value.clone()); + } + + None + } + + pub fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + let values = to_array(values); + + for (k, v) in &self.parameters { + // if (!\is_int($k)) { $v = $k; } + let v: PhpMixed = match k { + PhpMixed::Int(_) => v.clone(), + _ => k.clone(), + }; + + if only_params && matches!(&v, PhpMixed::String(s) if s == "--") { + return false; + } + + if values.iter().any(|x| x == &v) { + return true; + } + } + + false + } + + pub fn get_parameter_option( + &self, + values: PhpMixed, + default: PhpMixed, + only_params: bool, + ) -> PhpMixed { + let values = to_array(values); + + for (k, v) in &self.parameters { + // $onlyParams && ('--' === $k || (\is_int($k) && '--' === $v)) + if only_params { + let k_is_double_dash = matches!(k, PhpMixed::String(s) if s == "--"); + let int_v_double_dash = + matches!(k, PhpMixed::Int(_)) && matches!(v, PhpMixed::String(s) if s == "--"); + if k_is_double_dash || int_v_double_dash { + return default; + } + } + + match k { + PhpMixed::Int(_) => { + if values.iter().any(|x| x == v) { + return PhpMixed::Bool(true); + } + } + _ => { + if values.iter().any(|x| x == k) { + return v.clone(); + } + } + } + } + + default + } + + /// Returns a stringified representation of the args passed to the command. + pub fn to_string(&self) -> String { + let mut params: Vec<String> = vec![]; + for (param, val) in &self.parameters { + // $param && \is_string($param) && '-' === $param[0] + let is_option_key = + matches!(param, PhpMixed::String(s) if !s.is_empty() && s.as_bytes()[0] == b'-'); + if is_option_key { + let param = param.as_string().unwrap(); + let glue = if param.as_bytes().get(1) == Some(&b'-') { + "=" + } else { + " " + }; + if let PhpMixed::List(list) = val { + for v in list { + let v = shirabe_php_shim::php_to_string(v); + params.push(format!( + "{}{}", + param, + if v != "" { + format!("{}{}", glue, self.inner.escape_token(&v)) + } else { + String::new() + } + )); + } + } else { + let val = shirabe_php_shim::php_to_string(val); + params.push(format!( + "{}{}", + param, + if val != "" { + format!("{}{}", glue, self.inner.escape_token(&val)) + } else { + String::new() + } + )); + } + } else if let PhpMixed::List(list) = val { + let escaped: Vec<String> = list + .iter() + .map(|v| self.inner.escape_token(&shirabe_php_shim::php_to_string(v))) + .collect(); + params.push(shirabe_php_shim::implode(" ", &escaped)); + } else { + params.push( + self.inner + .escape_token(&shirabe_php_shim::php_to_string(val)), + ); + } + } + + shirabe_php_shim::implode(" ", ¶ms) + } + + fn parse(&mut self) -> anyhow::Result<()> { + // Clone to avoid borrowing self while mutating; PHP iterates over a copy semantically. + let parameters = self.parameters.clone(); + for (key, value) in parameters { + let key = shirabe_php_shim::php_to_string(&key); + if key == "--" { + return Ok(()); + } + if shirabe_php_shim::str_starts_with(&key, "--") { + self.add_long_option(&shirabe_php_shim::substr(&key, 2, None), value)?; + } else if shirabe_php_shim::str_starts_with(&key, "-") { + self.add_short_option(&shirabe_php_shim::substr(&key, 1, None), value)?; + } else { + self.add_argument(&PhpMixed::String(key), value)?; + } + } + + Ok(()) + } + + /// Adds a short option value. + fn add_short_option(&mut self, shortcut: &str, value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_shortcut(shortcut) { + return Err(InvalidOptionException(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(shortcut.to_string())], + ), + code: 0, + }, + )) + .into()); + } + + self.add_long_option( + &self + .inner + .definition + .get_option_for_shortcut(shortcut)? + .get_name() + .to_string(), + value, + ) + } + + /// Adds a long option value. + fn add_long_option(&mut self, name: &str, mut value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_option(name) { + if !self.inner.definition.has_negation(name) { + return Err(InvalidOptionException(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }, + )) + .into()); + } + + let option_name = self.inner.definition.negation_to_name(name)?; + self.inner + .options + .insert(option_name, PhpMixed::Bool(false)); + + return Ok(()); + } + + let option = self.inner.definition.get_option(name)?; + + if matches!(value, PhpMixed::Null) { + if option.is_value_required() { + return Err(InvalidOptionException(InvalidArgumentException( + shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option requires a value.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }, + )) + .into()); + } + + if !option.is_value_optional() { + value = PhpMixed::Bool(true); + } + } + + self.inner.options.insert(name.to_string(), value); + + Ok(()) + } + + /// Adds an argument value. + fn add_argument(&mut self, name: &PhpMixed, value: PhpMixed) -> anyhow::Result<()> { + if !self.inner.definition.has_argument(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[name.clone()], + ), + code: 0, + }) + .into(), + ); + } + + self.inner + .arguments + .insert(shirabe_php_shim::php_to_string(name), value); + + Ok(()) } } impl InputInterface for ArrayInput { fn get_first_argument(&self) -> Option<String> { - todo!() + ArrayInput::get_first_argument(self).map(|v| shirabe_php_shim::php_to_string(&v)) } - fn has_parameter_option(&self, _values: &[&str], _only_params: bool) -> bool { - todo!() + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + ArrayInput::has_parameter_option(self, values, only_params) } + fn get_parameter_option( &self, - _values: &[&str], - _default: PhpMixed, - _only_params: bool, + values: PhpMixed, + default: PhpMixed, + only_params: bool, ) -> PhpMixed { - todo!() + ArrayInput::get_parameter_option(self, values, default, only_params) } - fn bind(&mut self, _definition: &InputDefinition) -> anyhow::Result<()> { - todo!() + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + ArrayInput::bind(self, definition) } - fn validate(&self) -> anyhow::Result<()> { - todo!() + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() } + fn get_arguments(&self) -> IndexMap<String, PhpMixed> { - todo!() + self.inner.get_arguments() } - fn get_argument(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_argument(name) } - fn set_argument(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_argument(name, value) } - fn has_argument(&self, _name: &str) -> bool { - todo!() + + fn has_argument(&self, name: &str) -> bool { + self.inner.has_argument(name) } + fn get_options(&self) -> IndexMap<String, PhpMixed> { - todo!() + self.inner.get_options() } - fn get_option(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_option(name) } - fn set_option(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_option(name, value) } - fn has_option(&self, _name: &str) -> bool { - todo!() + + fn has_option(&self, name: &str) -> bool { + self.inner.has_option(name) } + fn is_interactive(&self) -> bool { - todo!() + self.inner.is_interactive() } - fn set_interactive(&mut self, _interactive: bool) { - todo!() + + fn set_interactive(&mut self, interactive: bool) { + self.inner.set_interactive(interactive) + } +} + +/// PHP `(array) $values` cast: a string becomes a single-element array. +fn to_array(values: PhpMixed) -> Vec<PhpMixed> { + match values { + PhpMixed::List(list) => list.into_iter().map(|v| *v).collect(), + PhpMixed::Array(array) => array.into_iter().map(|(_, v)| *v).collect(), + PhpMixed::Null => vec![], + other => vec![other], } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input.rs b/crates/shirabe-external-packages/src/symfony/console/input/input.rs new file mode 100644 index 0000000..4c878bc --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/input/input.rs @@ -0,0 +1,252 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::runtime_exception::RuntimeException; +use crate::symfony::console::input::input_definition::InputDefinition; +use indexmap::IndexMap; +use shirabe_php_shim::PhpMixed; + +/// Input is the base class for all concrete Input classes. +/// +/// Three concrete classes are provided by default: +/// +/// * `ArgvInput`: The input comes from the CLI arguments (argv) +/// * `StringInput`: The input is provided as a string +/// * `ArrayInput`: The input is provided as an array +#[derive(Debug)] +pub struct Input { + pub(crate) definition: InputDefinition, + pub(crate) stream: PhpMixed, + pub(crate) options: IndexMap<String, PhpMixed>, + pub(crate) arguments: IndexMap<String, PhpMixed>, + pub(crate) interactive: bool, +} + +impl Input { + pub fn new(definition: Option<InputDefinition>) -> anyhow::Result<Self> { + let mut input = Input { + definition: InputDefinition::new(vec![])?, + stream: PhpMixed::Null, + options: IndexMap::new(), + arguments: IndexMap::new(), + interactive: true, + }; + + match definition { + None => { + input.definition = InputDefinition::new(vec![])?; + } + Some(definition) => { + input.bind(&definition)?; + input.validate()?; + } + } + + Ok(input) + } + + pub fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + self.arguments = IndexMap::new(); + self.options = IndexMap::new(); + self.definition = definition.clone(); + + self.parse()?; + + Ok(()) + } + + /// Processes command line arguments. + /// + /// This is abstract in PHP; concrete subclasses provide their own `parse`. + /// Since `Input` is embedded via `inner` in the subclasses, the subclass + /// drives the parsing instead. + fn parse(&mut self) -> anyhow::Result<()> { + unreachable!("Input::parse is abstract and overridden by subclasses") + } + + pub fn validate(&mut self) -> anyhow::Result<()> { + let definition = &self.definition; + let given_arguments = &self.arguments; + + let missing_arguments: Vec<String> = shirabe_php_shim::array_filter( + &shirabe_php_shim::array_keys(definition.get_arguments()), + |argument: &String| { + !given_arguments.contains_key(argument) + && definition + .get_argument(&PhpMixed::String(argument.clone())) + .map(|a| a.is_required()) + .unwrap_or(false) + }, + ); + + if missing_arguments.len() > 0 { + return Err(RuntimeException(shirabe_php_shim::RuntimeException { + message: shirabe_php_shim::sprintf( + "Not enough arguments (missing: \"%s\").", + &[PhpMixed::String(shirabe_php_shim::implode( + ", ", + &missing_arguments, + ))], + ), + code: 0, + }) + .into()); + } + + Ok(()) + } + + pub fn is_interactive(&self) -> bool { + self.interactive + } + + pub fn set_interactive(&mut self, interactive: bool) { + self.interactive = interactive; + } + + pub fn get_arguments(&self) -> IndexMap<String, PhpMixed> { + shirabe_php_shim::array_merge_map( + self.definition.get_argument_defaults(), + self.arguments.clone(), + ) + } + + pub fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + if !self + .definition + .has_argument(&PhpMixed::String(name.to_string())) + { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + Ok(match self.arguments.get(name) { + Some(value) => value.clone(), + None => self + .definition + .get_argument(&PhpMixed::String(name.to_string()))? + .get_default() + .clone(), + }) + } + + pub fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + if !self + .definition + .has_argument(&PhpMixed::String(name.to_string())) + { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + self.arguments.insert(name.to_string(), value); + + Ok(()) + } + + pub fn has_argument(&self, name: &str) -> bool { + self.definition + .has_argument(&PhpMixed::String(name.to_string())) + } + + pub fn get_options(&self) -> IndexMap<String, PhpMixed> { + shirabe_php_shim::array_merge_map( + self.definition.get_option_defaults(), + self.options.clone(), + ) + } + + pub fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + if self.definition.has_negation(name) { + let value = self.get_option(&self.definition.negation_to_name(name)?)?; + if matches!(value, PhpMixed::Null) { + return Ok(value); + } + + return Ok(PhpMixed::Bool(!value.as_bool().unwrap_or(false))); + } + + if !self.definition.has_option(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + Ok(if self.options.contains_key(name) { + self.options[name].clone() + } else { + self.definition.get_option(name)?.get_default().clone() + }) + } + + pub fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + if self.definition.has_negation(name) { + let negated = self.definition.negation_to_name(name)?; + self.options + .insert(negated, PhpMixed::Bool(!value.as_bool().unwrap_or(false))); + + return Ok(()); + } else if !self.definition.has_option(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + self.options.insert(name.to_string(), value); + + Ok(()) + } + + pub fn has_option(&self, name: &str) -> bool { + self.definition.has_option(name) || self.definition.has_negation(name) + } + + /// Escapes a token through escapeshellarg if it contains unsafe chars. + pub fn escape_token(&self, token: &str) -> String { + let mut matches: Vec<Option<String>> = vec![]; + if shirabe_php_shim::preg_match("{^[\\w-]+$}", token, &mut matches) != 0 { + token.to_string() + } else { + shirabe_php_shim::escapeshellarg(token) + } + } + + pub fn set_stream(&mut self, stream: PhpMixed) { + self.stream = stream; + } + + pub fn get_stream(&self) -> Option<PhpMixed> { + match &self.stream { + PhpMixed::Null => None, + other => Some(other.clone()), + } + } +} diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs index 84b722d..e320878 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_argument.rs @@ -1,7 +1,14 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; use shirabe_php_shim::PhpMixed; #[derive(Debug)] -pub struct InputArgument; +pub struct InputArgument { + name: String, + mode: i64, + default: PhpMixed, + description: String, +} impl InputArgument { pub const REQUIRED: i64 = 1; @@ -9,27 +16,85 @@ impl InputArgument { pub const IS_ARRAY: i64 = 4; pub fn new( - _name: &str, - _mode: Option<i64>, - _description: &str, - _default: Option<PhpMixed>, - ) -> Self { - todo!() + name: String, + mode: Option<i64>, + description: String, + default: PhpMixed, + ) -> anyhow::Result<Self> { + let mode = match mode { + None => Self::OPTIONAL, + Some(m) if m > 7 || m < 1 => { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: format!("Argument mode \"{}\" is not valid.", m), + code: 0, + }) + .into(), + ); + } + Some(m) => m, + }; + + let mut argument = InputArgument { + name, + mode, + description, + default: PhpMixed::Null, + }; + + argument.set_default(default)?; + + Ok(argument) } - pub fn get_name(&self) -> String { - todo!() + pub fn get_name(&self) -> &str { + &self.name } pub fn is_required(&self) -> bool { - todo!() + Self::REQUIRED == (Self::REQUIRED & self.mode) } pub fn is_array(&self) -> bool { - todo!() + Self::IS_ARRAY == (Self::IS_ARRAY & self.mode) + } + + pub fn set_default(&mut self, default: PhpMixed) -> anyhow::Result<()> { + if self.is_required() && !matches!(default, PhpMixed::Null) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "Cannot set a default value except for InputArgument::OPTIONAL mode." + .to_string(), + code: 0, + }) + .into()); + } + + let default = if self.is_array() { + match default { + PhpMixed::Null => PhpMixed::List(vec![]), + PhpMixed::List(_) => default, + _ => { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "A default value for an array argument must be an array." + .to_string(), + code: 0, + }) + .into()); + } + } + } else { + default + }; + + self.default = default; + Ok(()) + } + + pub fn get_default(&self) -> &PhpMixed { + &self.default } - pub fn get_default(&self) -> Option<PhpMixed> { - todo!() + pub fn get_description(&self) -> &str { + &self.description } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_aware_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_aware_interface.rs new file mode 100644 index 0000000..ae46465 --- /dev/null +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_aware_interface.rs @@ -0,0 +1,5 @@ +use crate::symfony::console::input::input_interface::InputInterface; + +pub trait InputAwareInterface { + fn set_input(&mut self, input: Box<dyn InputInterface>); +} diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs index f2d0318..5a3eef5 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_definition.rs @@ -1,32 +1,472 @@ -use crate::symfony::console::input::InputArgument; -use crate::symfony::console::input::InputOption; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; +use crate::symfony::console::input::input_argument::InputArgument; +use crate::symfony::console::input::input_option::InputOption; +use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +use std::rc::Rc; +/// A InputDefinition represents a set of valid command line arguments and options. +/// +/// `InputArgument` and `InputOption` are stored behind `Rc` to model PHP's +/// shared object references; this also lets the definition be cloned cheaply +/// (PHP `bind` assigns the definition by reference). +#[derive(Debug, Clone)] +pub struct InputDefinition { + arguments: IndexMap<String, Rc<InputArgument>>, + required_count: i64, + last_array_argument: Option<Rc<InputArgument>>, + last_optional_argument: Option<Rc<InputArgument>>, + options: IndexMap<String, Rc<InputOption>>, + negations: IndexMap<String, String>, + shortcuts: IndexMap<String, String>, +} + +/// A definition entry is either an InputArgument or an InputOption. #[derive(Debug)] -pub struct InputDefinition; +pub enum DefinitionItem { + InputArgument(InputArgument), + InputOption(InputOption), +} impl InputDefinition { - pub fn new(_definition: Vec<PhpMixed>) -> Self { - todo!() + pub fn new(definition: Vec<DefinitionItem>) -> anyhow::Result<Self> { + let mut input_definition = InputDefinition { + arguments: IndexMap::new(), + required_count: 0, + last_array_argument: None, + last_optional_argument: None, + options: IndexMap::new(), + negations: IndexMap::new(), + shortcuts: IndexMap::new(), + }; + input_definition.set_definition(definition)?; + Ok(input_definition) + } + + /// Sets the definition of the input. + pub fn set_definition(&mut self, definition: Vec<DefinitionItem>) -> anyhow::Result<()> { + let mut arguments = vec![]; + let mut options = vec![]; + for item in definition { + match item { + DefinitionItem::InputOption(option) => { + options.push(option); + } + DefinitionItem::InputArgument(argument) => { + arguments.push(argument); + } + } + } + + self.set_arguments(arguments)?; + self.set_options(options)?; + + Ok(()) + } + + /// Sets the InputArgument objects. + pub fn set_arguments(&mut self, arguments: Vec<InputArgument>) -> anyhow::Result<()> { + self.arguments = IndexMap::new(); + self.required_count = 0; + self.last_optional_argument = None; + self.last_array_argument = None; + self.add_arguments(Some(arguments))?; + Ok(()) + } + + /// Adds an array of InputArgument objects. + pub fn add_arguments(&mut self, arguments: Option<Vec<InputArgument>>) -> anyhow::Result<()> { + if let Some(arguments) = arguments { + for argument in arguments { + self.add_argument(argument)?; + } + } + Ok(()) + } + + pub fn add_argument(&mut self, argument: InputArgument) -> anyhow::Result<()> { + let argument = Rc::new(argument); + + if self.arguments.contains_key(argument.get_name()) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An argument with name \"%s\" already exists.", + &[PhpMixed::String(argument.get_name().to_string())], + ), + code: 0, + }) + .into()); + } + + if let Some(last_array_argument) = &self.last_array_argument { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "Cannot add a required argument \"%s\" after an array argument \"%s\".", + &[ + PhpMixed::String(argument.get_name().to_string()), + PhpMixed::String(last_array_argument.get_name().to_string()), + ], + ), + code: 0, + }) + .into()); + } + + if argument.is_required() { + if let Some(last_optional_argument) = &self.last_optional_argument { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "Cannot add a required argument \"%s\" after an optional one \"%s\".", + &[ + PhpMixed::String(argument.get_name().to_string()), + PhpMixed::String(last_optional_argument.get_name().to_string()), + ], + ), + code: 0, + }) + .into()); + } + } + + if argument.is_array() { + self.last_array_argument = Some(Rc::clone(&argument)); + } + + if argument.is_required() { + self.required_count += 1; + } else { + self.last_optional_argument = Some(Rc::clone(&argument)); + } + + self.arguments + .insert(argument.get_name().to_string(), argument); + + Ok(()) + } + + /// Returns an InputArgument by name or by position. + pub fn get_argument(&self, name: &PhpMixed) -> anyhow::Result<Rc<InputArgument>> { + if !self.has_argument(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"%s\" argument does not exist.", + &[name.clone()], + ), + code: 0, + }) + .into(), + ); + } + + match name { + PhpMixed::Int(index) => { + let arguments: Vec<Rc<InputArgument>> = self.arguments.values().cloned().collect(); + Ok(Rc::clone(&arguments[*index as usize])) + } + _ => { + let key = shirabe_php_shim::php_to_string(name); + Ok(Rc::clone(&self.arguments[&key])) + } + } + } + + /// Returns true if an InputArgument object exists by name or position. + pub fn has_argument(&self, name: &PhpMixed) -> bool { + match name { + PhpMixed::Int(index) => { + let arguments: Vec<Rc<InputArgument>> = self.arguments.values().cloned().collect(); + *index >= 0 && (*index as usize) < arguments.len() + } + _ => { + let key = shirabe_php_shim::php_to_string(name); + self.arguments.contains_key(&key) + } + } + } + + /// Gets the array of InputArgument objects. + pub fn get_arguments(&self) -> &IndexMap<String, Rc<InputArgument>> { + &self.arguments + } + + /// Returns the number of InputArguments. + pub fn get_argument_count(&self) -> i64 { + if self.last_array_argument.is_some() { + i64::MAX + } else { + self.arguments.len() as i64 + } + } + + /// Returns the number of required InputArguments. + pub fn get_argument_required_count(&self) -> i64 { + self.required_count + } + + pub fn get_argument_defaults(&self) -> IndexMap<String, PhpMixed> { + let mut values = IndexMap::new(); + for argument in self.arguments.values() { + values.insert( + argument.get_name().to_string(), + argument.get_default().clone(), + ); + } + + values + } + + /// Sets the InputOption objects. + pub fn set_options(&mut self, options: Vec<InputOption>) -> anyhow::Result<()> { + self.options = IndexMap::new(); + self.shortcuts = IndexMap::new(); + self.negations = IndexMap::new(); + self.add_options(options)?; + Ok(()) + } + + /// Adds an array of InputOption objects. + pub fn add_options(&mut self, options: Vec<InputOption>) -> anyhow::Result<()> { + for option in options { + self.add_option(option)?; + } + Ok(()) + } + + pub fn add_option(&mut self, option: InputOption) -> anyhow::Result<()> { + let option = Rc::new(option); + + if let Some(existing) = self.options.get(option.get_name()) { + if !option.equals(existing) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option named \"%s\" already exists.", + &[PhpMixed::String(option.get_name().to_string())], + ), + code: 0, + }) + .into()); + } + } + if self.negations.contains_key(option.get_name()) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option named \"%s\" already exists.", + &[PhpMixed::String(option.get_name().to_string())], + ), + code: 0, + }) + .into()); + } + + if let Some(shortcut) = option.get_shortcut() { + for shortcut in shirabe_php_shim::explode("|", shortcut) { + if let Some(existing_name) = self.shortcuts.get(&shortcut) { + if !option.equals(&self.options[existing_name]) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option with shortcut \"%s\" already exists.", + &[PhpMixed::String(shortcut.clone())], + ), + code: 0, + }) + .into()); + } + } + } + } + + self.options + .insert(option.get_name().to_string(), Rc::clone(&option)); + if let Some(shortcut) = option.get_shortcut() { + for shortcut in shirabe_php_shim::explode("|", shortcut) { + self.shortcuts + .insert(shortcut, option.get_name().to_string()); + } + } + + if option.is_negatable() { + let negated_name = format!("no-{}", option.get_name()); + if self.options.contains_key(&negated_name) { + return Err(LogicException(shirabe_php_shim::LogicException { + message: shirabe_php_shim::sprintf( + "An option named \"%s\" already exists.", + &[PhpMixed::String(negated_name.clone())], + ), + code: 0, + }) + .into()); + } + self.negations + .insert(negated_name, option.get_name().to_string()); + } + + Ok(()) + } + + /// Returns an InputOption by name. + pub fn get_option(&self, name: &str) -> anyhow::Result<Rc<InputOption>> { + if !self.has_option(name) { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(name.to_string())], + ), + code: 0, + }) + .into(), + ); + } + + Ok(Rc::clone(&self.options[name])) } - pub fn add_argument(&mut self, _argument: InputArgument) { - todo!() + /// Returns true if an InputOption object exists by name. + /// + /// This method can't be used to check if the user included the option when + /// executing the command (use getOption() instead). + pub fn has_option(&self, name: &str) -> bool { + self.options.contains_key(name) } - pub fn add_option(&mut self, _option: InputOption) { - todo!() + /// Gets the array of InputOption objects. + pub fn get_options(&self) -> &IndexMap<String, Rc<InputOption>> { + &self.options } - pub fn has_option(&self, _name: &str) -> bool { - todo!() + /// Returns true if an InputOption object exists by shortcut. + pub fn has_shortcut(&self, name: &str) -> bool { + self.shortcuts.contains_key(name) } - pub fn get_option(&self, _name: &str) -> anyhow::Result<PhpMixed> { - todo!() + /// Returns true if an InputOption object exists by negated name. + pub fn has_negation(&self, name: &str) -> bool { + self.negations.contains_key(name) } - pub fn has_argument(&self, _name: &str) -> bool { - todo!() + /// Gets an InputOption by shortcut. + pub fn get_option_for_shortcut(&self, shortcut: &str) -> anyhow::Result<Rc<InputOption>> { + self.get_option(&self.shortcut_to_name(shortcut)?) + } + + pub fn get_option_defaults(&self) -> IndexMap<String, PhpMixed> { + let mut values = IndexMap::new(); + for option in self.options.values() { + values.insert(option.get_name().to_string(), option.get_default().clone()); + } + + values + } + + /// Returns the InputOption name given a shortcut. + pub fn shortcut_to_name(&self, shortcut: &str) -> anyhow::Result<String> { + match self.shortcuts.get(shortcut) { + None => Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"-%s\" option does not exist.", + &[PhpMixed::String(shortcut.to_string())], + ), + code: 0, + }) + .into(), + ), + Some(name) => Ok(name.clone()), + } + } + + /// Returns the InputOption name given a negation. + pub fn negation_to_name(&self, negation: &str) -> anyhow::Result<String> { + match self.negations.get(negation) { + None => Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "The \"--%s\" option does not exist.", + &[PhpMixed::String(negation.to_string())], + ), + code: 0, + }) + .into(), + ), + Some(name) => Ok(name.clone()), + } + } + + /// Gets the synopsis. + pub fn get_synopsis(&self, short: bool) -> String { + let mut elements: Vec<String> = vec![]; + + if short && !self.get_options().is_empty() { + elements.push("[options]".to_string()); + } else if !short { + for option in self.get_options().values() { + let mut value = String::new(); + if option.accept_value() { + value = shirabe_php_shim::sprintf( + " %s%s%s", + &[ + PhpMixed::String(if option.is_value_optional() { + "[".to_string() + } else { + String::new() + }), + PhpMixed::String(shirabe_php_shim::strtoupper(option.get_name())), + PhpMixed::String(if option.is_value_optional() { + "]".to_string() + } else { + String::new() + }), + ], + ); + } + + let shortcut = match option.get_shortcut() { + Some(shortcut) => { + shirabe_php_shim::sprintf("-%s|", &[PhpMixed::String(shortcut.to_string())]) + } + None => String::new(), + }; + let negation = if option.is_negatable() { + shirabe_php_shim::sprintf( + "|--no-%s", + &[PhpMixed::String(option.get_name().to_string())], + ) + } else { + String::new() + }; + elements.push(shirabe_php_shim::sprintf( + "[%s--%s%s%s]", + &[ + PhpMixed::String(shortcut), + PhpMixed::String(option.get_name().to_string()), + PhpMixed::String(value), + PhpMixed::String(negation), + ], + )); + } + } + + if elements.len() > 0 && !self.get_arguments().is_empty() { + elements.push("[--]".to_string()); + } + + let mut tail = String::new(); + for argument in self.get_arguments().values() { + let mut element = format!("<{}>", argument.get_name()); + if argument.is_array() { + element.push_str("..."); + } + + if !argument.is_required() { + element = format!("[{}", element); + tail.push(']'); + } + + elements.push(element); + } + + format!("{}{}", shirabe_php_shim::implode(" ", &elements), tail) } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs index 14458e4..d271845 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs @@ -1,32 +1,39 @@ +use crate::symfony::console::input::input_definition::InputDefinition; use shirabe_php_shim::PhpMixed; -pub trait InputInterface: std::fmt::Debug { +pub trait InputInterface: std::fmt::Debug + shirabe_php_shim::AsAny { fn get_first_argument(&self) -> Option<String>; - fn has_parameter_option(&self, values: &[&str], only_params: bool) -> bool; + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool; + fn get_parameter_option( &self, - values: &[&str], + values: PhpMixed, default: PhpMixed, only_params: bool, ) -> PhpMixed; - fn bind( - &mut self, - definition: &crate::symfony::console::input::InputDefinition, - ) -> anyhow::Result<()>; - fn validate(&self) -> anyhow::Result<()>; + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()>; + + fn validate(&mut self) -> anyhow::Result<()>; + fn get_arguments(&self) -> indexmap::IndexMap<String, PhpMixed>; - fn get_argument(&self, name: &str) -> PhpMixed; + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed>; + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()>; + fn has_argument(&self, name: &str) -> bool; + fn get_options(&self) -> indexmap::IndexMap<String, PhpMixed>; - fn get_option(&self, name: &str) -> PhpMixed; + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed>; + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()>; + fn has_option(&self, name: &str) -> bool; + fn is_interactive(&self) -> bool; - fn set_interactive(&mut self, interactive: bool); - /// Equivalent to PHP `(string) $input` (Input::__toString). - fn to_input_string(&self) -> String { - todo!() - } + fn set_interactive(&mut self, interactive: bool); } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs index 673ca07..c929d03 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_option.rs @@ -1,7 +1,15 @@ +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::exception::logic_exception::LogicException; use shirabe_php_shim::PhpMixed; #[derive(Debug)] -pub struct InputOption; +pub struct InputOption { + name: String, + shortcut: Option<String>, + mode: i64, + default: PhpMixed, + description: String, +} impl InputOption { pub const VALUE_NONE: i64 = 1; @@ -11,36 +19,190 @@ impl InputOption { pub const VALUE_NEGATABLE: i64 = 16; pub fn new( - _name: &str, - _shortcut: Option<&str>, - _mode: Option<i64>, - _description: &str, - _default: PhpMixed, - ) -> Self { - todo!() + name: &str, + shortcut: PhpMixed, + mode: Option<i64>, + description: String, + default: PhpMixed, + ) -> anyhow::Result<Self> { + let name = if name.starts_with("--") { + name[2..].to_string() + } else { + name.to_string() + }; + + if name.is_empty() { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "An option name cannot be empty.".to_string(), + code: 0, + }) + .into(), + ); + } + + let shortcut = match shortcut { + PhpMixed::String(ref s) if s.is_empty() => None, + PhpMixed::List(ref v) if v.is_empty() => None, + PhpMixed::Bool(false) => None, + PhpMixed::Null => None, + PhpMixed::List(ref arr) => { + let parts: Vec<String> = arr + .iter() + .filter_map(|v| { + if let PhpMixed::String(s) = v.as_ref() { + Some(s.clone()) + } else { + None + } + }) + .collect(); + let joined = shirabe_php_shim::implode("|", &parts); + Self::normalize_shortcut(joined)? + } + PhpMixed::String(s) => Self::normalize_shortcut(s)?, + _ => None, + }; + + let mode = match mode { + None => Self::VALUE_NONE, + Some(m) if m >= (Self::VALUE_NEGATABLE << 1) || m < 1 => { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: format!("Option mode \"{}\" is not valid.", m), + code: 0, + }) + .into(), + ); + } + Some(m) => m, + }; + + let mut option = InputOption { + name, + shortcut, + mode, + description, + default: PhpMixed::Null, + }; + + if option.is_array() && !option.accept_value() { + return Err(InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "Impossible to have an option mode VALUE_IS_ARRAY if the option does not accept a value.".to_string(), + code: 0, + }) + .into()); + } + if option.is_negatable() && option.accept_value() { + return Err(InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "Impossible to have an option mode VALUE_NEGATABLE if the option also accepts a value.".to_string(), + code: 0, + }) + .into()); + } + + option.set_default(default)?; + + Ok(option) + } + + fn normalize_shortcut(s: String) -> anyhow::Result<Option<String>> { + let stripped = shirabe_php_shim::ltrim(&s, Some("-")); + let parts = shirabe_php_shim::preg_split(r"\|(-?)", &stripped).unwrap_or_default(); + let filtered: Vec<String> = + shirabe_php_shim::array_filter(&parts, |s: &String| !s.is_empty()); + let result = shirabe_php_shim::implode("|", &filtered); + if result.is_empty() { + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: "An option shortcut cannot be empty.".to_string(), + code: 0, + }) + .into(), + ); + } + Ok(Some(result)) + } + + pub fn get_shortcut(&self) -> Option<&str> { + self.shortcut.as_deref() } - pub fn get_name(&self) -> String { - todo!() + pub fn get_name(&self) -> &str { + &self.name } pub fn accept_value(&self) -> bool { - todo!() + self.is_value_required() || self.is_value_optional() } pub fn is_value_required(&self) -> bool { - todo!() + Self::VALUE_REQUIRED == (Self::VALUE_REQUIRED & self.mode) } pub fn is_value_optional(&self) -> bool { - todo!() + Self::VALUE_OPTIONAL == (Self::VALUE_OPTIONAL & self.mode) } pub fn is_array(&self) -> bool { - todo!() + Self::VALUE_IS_ARRAY == (Self::VALUE_IS_ARRAY & self.mode) + } + + pub fn is_negatable(&self) -> bool { + Self::VALUE_NEGATABLE == (Self::VALUE_NEGATABLE & self.mode) + } + + pub fn set_default(&mut self, default: PhpMixed) -> anyhow::Result<()> { + if Self::VALUE_NONE == (Self::VALUE_NONE & self.mode) && !matches!(default, PhpMixed::Null) + { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "Cannot set a default value when using InputOption::VALUE_NONE mode." + .to_string(), + code: 0, + }) + .into()); + } + + let default = if self.is_array() { + match default { + PhpMixed::Null => PhpMixed::List(vec![]), + PhpMixed::List(_) => default, + _ => { + return Err(LogicException(shirabe_php_shim::LogicException { + message: "A default value for an array option must be an array." + .to_string(), + code: 0, + }) + .into()); + } + } + } else { + default + }; + + self.default = if self.accept_value() || self.is_negatable() { + default + } else { + PhpMixed::Bool(false) + }; + Ok(()) + } + + pub fn get_default(&self) -> &PhpMixed { + &self.default + } + + pub fn get_description(&self) -> &str { + &self.description } - pub fn get_default(&self) -> PhpMixed { - todo!() + pub fn equals(&self, option: &InputOption) -> bool { + option.get_name() == self.get_name() + && option.get_shortcut() == self.get_shortcut() + && option.get_default() == self.get_default() + && option.is_negatable() == self.is_negatable() + && option.is_array() == self.is_array() + && option.is_value_required() == self.is_value_required() + && option.is_value_optional() == self.is_value_optional() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/mod.rs b/crates/shirabe-external-packages/src/symfony/console/input/mod.rs index 8c6a28f..d4813b4 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/mod.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/mod.rs @@ -1,13 +1,19 @@ +pub mod argv_input; pub mod array_input; +pub mod input; pub mod input_argument; +pub mod input_aware_interface; pub mod input_definition; pub mod input_interface; pub mod input_option; pub mod streamable_input_interface; pub mod string_input; +pub use argv_input::*; pub use array_input::*; +pub use input::*; pub use input_argument::*; +pub use input_aware_interface::*; pub use input_definition::*; pub use input_interface::*; pub use input_option::*; diff --git a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs index aea2bf2..7279dc4 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs @@ -1,7 +1,8 @@ -use crate::symfony::console::input::InputInterface; +use crate::symfony::console::input::input_interface::InputInterface; use shirabe_php_shim::PhpMixed; pub trait StreamableInputInterface: InputInterface { fn set_stream(&mut self, stream: PhpMixed); + fn get_stream(&self) -> Option<PhpMixed>; } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs index 34b2bfc..aad1d86 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/string_input.rs @@ -1,66 +1,193 @@ -use crate::symfony::console::input::InputDefinition; -use crate::symfony::console::input::InputInterface; +use crate::symfony::console::exception::invalid_argument_exception::InvalidArgumentException; +use crate::symfony::console::input::argv_input::ArgvInput; +use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::input_interface::InputInterface; use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +/// StringInput represents an input provided as a string. +/// +/// Usage: +/// +/// $input = new StringInput('foo --bar="foobar"'); #[derive(Debug)] -pub struct StringInput; +pub struct StringInput { + pub(crate) inner: ArgvInput, +} impl StringInput { - pub fn new(_input: &str) -> Self { - todo!() + pub const REGEX_STRING: &'static str = r#"([^\s]+?)(?:\s|(?<!\\)"|(?<!\\)'|$)"#; + pub const REGEX_UNQUOTED_STRING: &'static str = r#"([^\s\\]+?)"#; + pub const REGEX_QUOTED_STRING: &'static str = + r#"(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)')"#; + + pub fn new(input: &str) -> anyhow::Result<Self> { + // parent::__construct([]) + let inner = ArgvInput::new(Some(vec![]), None)?; + + let mut string_input = StringInput { inner }; + + let tokens = string_input.tokenize(input)?; + string_input.inner.set_tokens(tokens); + + Ok(string_input) + } + + /// Tokenizes a string. + fn tokenize(&self, input: &str) -> anyhow::Result<Vec<String>> { + let bytes = input.as_bytes(); + let mut tokens: Vec<String> = vec![]; + let length = shirabe_php_shim::strlen(input); + let mut cursor: i64 = 0; + let mut token: Option<String> = None; + while cursor < length { + if bytes[cursor as usize] == b'\\' { + cursor += 1; + let next: String = match bytes.get(cursor as usize) { + Some(b) => String::from_utf8_lossy(&[*b]).into_owned(), + None => String::new(), + }; + token = Some(format!("{}{}", token.unwrap_or_default(), next)); + cursor += 1; + continue; + } + + let mut m: Vec<String> = vec![]; + if shirabe_php_shim::preg_match_offset(r"/\s+/A", input, &mut m, 0, cursor) { + if token.is_some() { + tokens.push(token.take().unwrap()); + } + cursor += shirabe_php_shim::strlen(&m[0]); + } else if shirabe_php_shim::preg_match_offset( + &format!(r#"/([^="'\s]+?)(=?)({}+)/A"#, Self::REGEX_QUOTED_STRING), + input, + &mut m, + 0, + cursor, + ) { + let inner = shirabe_php_shim::substr(&m[3], 1, Some(-1)); + let replaced = + shirabe_php_shim::str_replace_arr(&["\"'", "'\"", "''", "\"\""], "", &inner); + token = Some(format!( + "{}{}{}{}", + token.unwrap_or_default(), + m[1], + m[2], + shirabe_php_shim::stripcslashes(&replaced) + )); + cursor += shirabe_php_shim::strlen(&m[0]); + } else if shirabe_php_shim::preg_match_offset( + &format!(r"/{}/A", Self::REGEX_QUOTED_STRING), + input, + &mut m, + 0, + cursor, + ) { + token = Some(format!( + "{}{}", + token.unwrap_or_default(), + shirabe_php_shim::stripcslashes(&shirabe_php_shim::substr(&m[0], 1, Some(-1))) + )); + cursor += shirabe_php_shim::strlen(&m[0]); + } else if shirabe_php_shim::preg_match_offset( + &format!(r"/{}/A", Self::REGEX_UNQUOTED_STRING), + input, + &mut m, + 0, + cursor, + ) { + token = Some(format!("{}{}", token.unwrap_or_default(), m[1])); + cursor += shirabe_php_shim::strlen(&m[0]); + } else { + // should never happen + return Err( + InvalidArgumentException(shirabe_php_shim::InvalidArgumentException { + message: shirabe_php_shim::sprintf( + "Unable to parse input near \"... %s ...\".", + &[PhpMixed::String(shirabe_php_shim::substr( + input, + cursor, + Some(10), + ))], + ), + code: 0, + }) + .into(), + ); + } + } + + if let Some(token) = token { + tokens.push(token); + } + + Ok(tokens) } } impl InputInterface for StringInput { fn get_first_argument(&self) -> Option<String> { - todo!() + self.inner.get_first_argument() } - fn has_parameter_option(&self, _values: &[&str], _only_params: bool) -> bool { - todo!() + + fn has_parameter_option(&self, values: PhpMixed, only_params: bool) -> bool { + InputInterface::has_parameter_option(&self.inner, values, only_params) } + fn get_parameter_option( &self, - _values: &[&str], - _default: PhpMixed, - _only_params: bool, + values: PhpMixed, + default: PhpMixed, + only_params: bool, ) -> PhpMixed { - todo!() + InputInterface::get_parameter_option(&self.inner, values, default, only_params) } - fn bind(&mut self, _definition: &InputDefinition) -> anyhow::Result<()> { - todo!() + + fn bind(&mut self, definition: &InputDefinition) -> anyhow::Result<()> { + InputInterface::bind(&mut self.inner, definition) } - fn validate(&self) -> anyhow::Result<()> { - todo!() + + fn validate(&mut self) -> anyhow::Result<()> { + self.inner.validate() } + fn get_arguments(&self) -> IndexMap<String, PhpMixed> { - todo!() + InputInterface::get_arguments(&self.inner) } - fn get_argument(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_argument(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_argument(name) } - fn set_argument(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_argument(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_argument(name, value) } - fn has_argument(&self, _name: &str) -> bool { - todo!() + + fn has_argument(&self, name: &str) -> bool { + self.inner.has_argument(name) } + fn get_options(&self) -> IndexMap<String, PhpMixed> { - todo!() + InputInterface::get_options(&self.inner) } - fn get_option(&self, _name: &str) -> PhpMixed { - todo!() + + fn get_option(&self, name: &str) -> anyhow::Result<PhpMixed> { + self.inner.get_option(name) } - fn set_option(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { - todo!() + + fn set_option(&mut self, name: &str, value: PhpMixed) -> anyhow::Result<()> { + self.inner.set_option(name, value) } - fn has_option(&self, _name: &str) -> bool { - todo!() + + fn has_option(&self, name: &str) -> bool { + self.inner.has_option(name) } + fn is_interactive(&self) -> bool { - todo!() + self.inner.is_interactive() } - fn set_interactive(&mut self, _interactive: bool) { - todo!() + + fn set_interactive(&mut self, interactive: bool) { + self.inner.set_interactive(interactive) } } |
