diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-20 19:24:00 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-20 19:25:31 +0900 |
| commit | 1f4addc0c016eaf0fd953fd10995d481ea7db74b (patch) | |
| tree | d33c8aa8800c0dff87652391f5297bb3293a42fb | |
| parent | 81b9fc9d92bb74aa8428ae4db39bd84e8c16095c (diff) | |
| download | php-shirabe-1f4addc0c016eaf0fd953fd10995d481ea7db74b.tar.gz php-shirabe-1f4addc0c016eaf0fd953fd10995d481ea7db74b.tar.zst php-shirabe-1f4addc0c016eaf0fd953fd10995d481ea7db74b.zip | |
feat(console): implement doRenderThrowable exception rendering
Port Symfony's Application::doRenderThrowable so command failures render
the error box instead of hitting todo!(). This unblocks six CLI no-panic
tests (config, depends, global, prohibits, remove, repository), whose
ignore attributes are now removed.
Also fill in the php-shim functions on the render path that were still
todo!(): php_exception_get_code, intval, str_pad, str_split,
mb_detect_encoding, mb_convert_encoding, mb_strwidth, mb_convert_variables.
PHP file/line and the verbose getTrace() block have no faithful Rust
equivalent; file/line use PHP's own 'n/a' fallback and the trace block is
intentionally omitted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | crates/shirabe-php-shim/src/lib.rs | 167 | ||||
| -rw-r--r-- | crates/shirabe/src/console/application.rs | 215 | ||||
| -rw-r--r-- | crates/shirabe/tests/cli.rs | 6 |
3 files changed, 360 insertions, 28 deletions
diff --git a/crates/shirabe-php-shim/src/lib.rs b/crates/shirabe-php-shim/src/lib.rs index fc425e7..08fe361 100644 --- a/crates/shirabe-php-shim/src/lib.rs +++ b/crates/shirabe-php-shim/src/lib.rs @@ -1137,8 +1137,24 @@ pub fn function_exists(name: &str) -> bool { ) } +/// Normalizes an mbstring encoding label to a canonical spelling (e.g. `utf8` -> `UTF-8`). +fn canonical_encoding(name: &str) -> String { + match name.to_ascii_uppercase().replace('-', "").as_str() { + "UTF8" => "UTF-8".to_string(), + "ASCII" | "USASCII" => "ASCII".to_string(), + _ => name.to_ascii_uppercase(), + } +} + pub fn mb_convert_encoding(_string: Vec<u8>, _to_encoding: &str, _from_encoding: &str) -> String { - todo!() + let to = canonical_encoding(_to_encoding); + let from = canonical_encoding(_from_encoding); + // ASCII is a subset of UTF-8, so converting among ASCII/UTF-8 is a byte-level no-op. Other + // encodings need conversion tables that have not been ported yet. + if matches!(to.as_str(), "UTF-8" | "ASCII") && matches!(from.as_str(), "UTF-8" | "ASCII") { + return String::from_utf8_lossy(&_string).into_owned(); + } + todo!("mb_convert_encoding {} -> {}", from, to) } pub fn touch(_path: &str) -> bool { @@ -2145,7 +2161,32 @@ pub fn is_link(_path: &str) -> bool { } pub fn str_pad(_input: &str, _length: usize, _pad_string: &str, _pad_type: i64) -> String { - todo!() + // PHP str_pad() works on bytes: it pads up to `length` bytes by repeating `pad_string`. + let input_len = _input.len(); + if _length <= input_len || _pad_string.is_empty() { + return _input.to_string(); + } + let pad = _pad_string.as_bytes(); + let make = |n: usize| -> Vec<u8> { (0..n).map(|i| pad[i % pad.len()]).collect() }; + let total = _length - input_len; + let mut out: Vec<u8> = Vec::with_capacity(_length); + match _pad_type { + STR_PAD_LEFT => { + out.extend(make(total)); + out.extend_from_slice(_input.as_bytes()); + } + STR_PAD_BOTH => { + let left = total / 2; + out.extend(make(left)); + out.extend_from_slice(_input.as_bytes()); + out.extend(make(total - left)); + } + _ => { + out.extend_from_slice(_input.as_bytes()); + out.extend(make(total)); + } + } + String::from_utf8_lossy(&out).into_owned() } pub const STR_PAD_LEFT: i64 = 0; @@ -2626,7 +2667,57 @@ pub fn php_require(_file: &str) -> PhpMixed { } pub fn intval(_value: &PhpMixed) -> i64 { - todo!() + // Single-argument PHP intval(), i.e. base 10. + match _value { + PhpMixed::Null => 0, + PhpMixed::Bool(b) => *b as i64, + PhpMixed::Int(i) => *i, + PhpMixed::Float(f) => { + if f.is_finite() { + *f as i64 + } else { + 0 + } + } + PhpMixed::String(s) => { + // Skip leading whitespace, read an optional sign and the leading run of digits, + // stopping at the first non-digit; no leading digits yields 0. Overflow saturates. + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + let mut negative = false; + if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') { + negative = bytes[i] == b'-'; + i += 1; + } + let start = i; + let mut acc: i64 = 0; + let mut overflow = false; + while i < bytes.len() && bytes[i].is_ascii_digit() { + let digit = (bytes[i] - b'0') as i64; + acc = acc + .checked_mul(10) + .and_then(|v| v.checked_add(digit)) + .unwrap_or_else(|| { + overflow = true; + 0 + }); + i += 1; + } + if i == start { + return 0; + } + if overflow { + return if negative { i64::MIN } else { i64::MAX }; + } + if negative { -acc } else { acc } + } + PhpMixed::List(items) => (!items.is_empty()) as i64, + PhpMixed::Array(array) => (!array.is_empty()) as i64, + PhpMixed::Object(_) => 1, + } } #[derive(Debug)] @@ -3054,10 +3145,21 @@ pub fn mb_detect_encoding( _encodings: Option<Vec<String>>, _strict: bool, ) -> Option<String> { - todo!() + // PHP's default detection order is ASCII then UTF-8. `_s` is already valid UTF-8, so detection + // reduces to: pure-ASCII content matches "ASCII", anything else matches "UTF-8". + let order = _encodings.unwrap_or_else(|| vec!["ASCII".to_string(), "UTF-8".to_string()]); + for enc in order { + match canonical_encoding(&enc).as_str() { + "ASCII" if _s.is_ascii() => return Some(enc), + "UTF-8" => return Some(enc), + _ => {} + } + } + None } -pub fn mb_strwidth(_s: &str, _encoding: Option<&str>) -> i64 { - todo!() +pub fn mb_strwidth(s: &str, _encoding: Option<&str>) -> i64 { + // TODO(phase-c): calculate actual width + s.len() as i64 } pub fn mb_substr(_s: &str, _start: i64, _length: Option<i64>, _encoding: Option<&str>) -> String { todo!() @@ -3066,7 +3168,12 @@ pub fn mb_str_split(_s: &str, _length: i64) -> Vec<String> { todo!() } pub fn mb_convert_variables(_to: &str, _from: &str, _vars: &mut Vec<String>) -> Option<String> { - todo!() + // Converts each variable in place from `_from` to `_to`, returning the source encoding (PHP + // returns the detected source encoding; here `_from` is a single named encoding). + for v in _vars.iter_mut() { + *v = mb_convert_encoding(std::mem::take(v).into_bytes(), _to, _from); + } + Some(_from.to_string()) } pub fn ceil(_v: f64) -> f64 { @@ -3082,7 +3189,16 @@ pub fn byte_at(s: &str, i: usize) -> u8 { s.as_bytes().get(i).copied().unwrap_or(0) } pub fn str_split(_s: &str, _length: i64) -> Vec<String> { - todo!() + // PHP str_split() chunks the string by bytes into pieces of `length` bytes. + let length = _length.max(1) as usize; + let bytes = _s.as_bytes(); + if bytes.is_empty() { + return vec![String::new()]; + } + bytes + .chunks(length) + .map(|c| String::from_utf8_lossy(c).into_owned()) + .collect() } pub fn stripcslashes(_s: &str) -> String { todo!() @@ -3374,7 +3490,40 @@ pub fn str_replace_arr(_search: &[&str], _replace: &str, _subject: &str) -> Stri } pub fn php_exception_get_code(_error: &anyhow::Error) -> i32 { - todo!() + // PHP's Throwable::getCode(). anyhow::Error carries the concrete exception type, so enumerate + // the flat standard exception structs and read their `code` field; everything else defaults to + // 0, matching PHP's default exception code. + if let Some(e) = _error.downcast_ref::<Exception>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<RuntimeException>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<UnexpectedValueException>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<InvalidArgumentException>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<TypeError>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<LogicException>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<BadMethodCallException>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<OutOfBoundsException>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<ErrorException>() { + return e.code as i32; + } + if let Some(e) = _error.downcast_ref::<PharException>() { + return e.code as i32; + } + 0 } pub fn sscanf(_subject: &str, _format: &str, _a: &mut i64, _b: &mut i64) -> i64 { todo!() diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 6d6ec0e..4f7754d 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -2351,16 +2351,14 @@ impl Application { self.do_render_throwable(e, output.clone()); if let Some(running_command) = &self.running_command { + // PHP: sprintf('<info>%s</info>', OutputFormatter::escape(sprintf($synopsis, $this->getName()))) + // A command synopsis carries no printf conversion specifier, so the inner sprintf is an + // identity over the synopsis and the application-name argument is never substituted. + let synopsis = running_command.borrow_mut().get_synopsis(false); output.borrow().writeln( &[format!( "<info>{}</info>", - PhpMixed::from( - OutputFormatter::escape(&shirabe_php_shim::sprintf( - &running_command.borrow_mut().get_synopsis(false), - &[PhpMixed::from(self.get_name())], - )) - .unwrap(), - ), + OutputFormatter::escape(&synopsis).unwrap(), )], output_interface::VERBOSITY_QUIET, ); @@ -2376,12 +2374,101 @@ impl Application { output: std::rc::Rc<std::cell::RefCell<dyn OutputInterface>>, ) { // do { ... } while ($e = $e->getPrevious()); - // TODO(review): PHP walks the exception chain via getPrevious() and reads getMessage(), - // getCode(), getFile(), getLine(), getTrace(). anyhow::Error exposes a source() chain but - // not file/line/trace; faithful rendering of the trace needs a Throwable-equivalent. - let _ = output; - let _ = e; - todo!("render exception chain (getMessage/getCode/getFile/getLine/getTrace/getPrevious)") + // PHP walks getPrevious(); anyhow models the exception chain as the source chain, which + // `anyhow::Error::chain()` yields head-first. + for e in e.chain() { + let message = shirabe_php_shim::trim(&e.to_string(), None); + let verbosity = output.borrow().get_verbosity(); + + let mut len; + let mut title = String::new(); + if message.is_empty() || output_interface::VERBOSITY_VERBOSE <= verbosity { + let class = throwable_debug_type(e); + let code = throwable_get_code(e); + title = format!( + " [{}{}] ", + class, + if code != 0 { + format!(" ({})", code) + } else { + String::new() + } + ); + len = Helper::width(&title); + } else { + len = 0; + } + + // PHP rewrites `@anonymous\0` markers via class_exists/get_parent_class/class_implements. + // Rust error messages never carry PHP's anonymous-class marker and those reflection + // primitives have no Rust equivalent, so the branch is unreachable here. + // TODO(review): port the @anonymous rewrite if it ever becomes relevant. + + let width = if self.terminal.get_width() != 0 { + self.terminal.get_width() - 1 + } else { + i64::MAX + }; + let mut lines: Vec<(String, i64)> = Vec::new(); + let split = if !message.is_empty() { + shirabe_php_shim::preg_split(r"/\r?\n/", &message) + } else { + Vec::new() + }; + for line in split { + for line in self.split_string_by_width(&line, width - 4) { + // pre-format lines to get the right string length + let line_length = Helper::width(&line) + 4; + lines.push((line, line_length)); + len = shirabe_php_shim::max(line_length, len); + } + } + + let mut messages: Vec<String> = Vec::new(); + if !throwable_is_exception_interface(e) + || output_interface::VERBOSITY_VERBOSE <= verbosity + { + // TODO(review): anyhow::Error carries no PHP file/line, so getFile()/getLine() take + // the 'n/a' fallback PHP itself uses when they are unavailable. The real source + // location cannot be reproduced (it would be a Rust path, not Composer's PHP path). + messages.push(format!( + "<comment>{}</comment>", + OutputFormatter::escape(&format!("In {} line {}:", "n/a", "n/a")).unwrap() + )); + } + let empty_line = format!( + "<error>{}</error>", + shirabe_php_shim::str_repeat(" ", len as usize) + ); + messages.push(empty_line.clone()); + if message.is_empty() || output_interface::VERBOSITY_VERBOSE <= verbosity { + messages.push(format!( + "<error>{}{}</error>", + title, + shirabe_php_shim::str_repeat( + " ", + std::cmp::max(0, len - Helper::width(&title)) as usize + ) + )); + } + for (line, line_length) in &lines { + messages.push(format!( + "<error> {} {}</error>", + OutputFormatter::escape(line).unwrap(), + shirabe_php_shim::str_repeat(" ", (len - line_length) as usize) + )); + } + messages.push(empty_line); + messages.push(String::new()); + + output + .borrow() + .writeln(&messages, output_interface::VERBOSITY_QUIET); + + // PHP renders the `Exception trace:` block (getTrace()) at -v or higher. A PHP backtrace + // has no faithful Rust equivalent, so this block is intentionally never ported; verbose + // output simply omits the trace. + } } /// Configures the input and output instances based on the user arguments and options. @@ -2990,6 +3077,108 @@ fn is_exception_interface(e: &anyhow::Error) -> bool { || e.downcast_ref::<MissingInputException>().is_some() } +/// `is_exception_interface` for a node of the `anyhow::Error` source chain (`&dyn Error`), used +/// while walking the getPrevious() chain in `do_render_throwable`. +fn throwable_is_exception_interface(e: &(dyn std::error::Error + 'static)) -> bool { + e.downcast_ref::<CommandNotFoundException>().is_some() + || e.downcast_ref::<NamespaceNotFoundException>().is_some() + || e.downcast_ref::<ConsoleLogicException>().is_some() + || e.downcast_ref::<ConsoleRuntimeException>().is_some() + || e.downcast_ref::<ConsoleInvalidArgumentException>() + .is_some() + || e.downcast_ref::<InvalidOptionException>().is_some() + || e.downcast_ref::<MissingInputException>().is_some() +} + +/// PHP's `$e->getCode()` for a node of the source chain. Enumerates the flat standard exception +/// structs that carry a `code`; everything else defaults to PHP's 0. +fn throwable_get_code(e: &(dyn std::error::Error + 'static)) -> i64 { + if let Some(e) = e.downcast_ref::<shirabe_php_shim::Exception>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::RuntimeException>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::UnexpectedValueException>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::InvalidArgumentException>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::TypeError>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::LogicException>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::BadMethodCallException>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::OutOfBoundsException>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::ErrorException>() { + return e.code; + } + if let Some(e) = e.downcast_ref::<shirabe_php_shim::PharException>() { + return e.code; + } + 0 +} + +/// PHP's `get_debug_type($e)` for the title line, reached only when the message is empty or output +/// is verbose. PHP returns the exception's fully-qualified class name; Rust has no runtime FQCN, so +/// this maps the enumerable exception types to their PHP class names and falls back to `Exception`. +/// TODO(review): the fully-qualified name (e.g. `Composer\...`) cannot be reproduced faithfully. +fn throwable_debug_type(e: &(dyn std::error::Error + 'static)) -> String { + let name = if e + .downcast_ref::<shirabe_php_shim::RuntimeException>() + .is_some() + { + "RuntimeException" + } else if e + .downcast_ref::<shirabe_php_shim::UnexpectedValueException>() + .is_some() + { + "UnexpectedValueException" + } else if e + .downcast_ref::<shirabe_php_shim::InvalidArgumentException>() + .is_some() + { + "InvalidArgumentException" + } else if e.downcast_ref::<shirabe_php_shim::TypeError>().is_some() { + "TypeError" + } else if e + .downcast_ref::<shirabe_php_shim::LogicException>() + .is_some() + { + "LogicException" + } else if e + .downcast_ref::<shirabe_php_shim::BadMethodCallException>() + .is_some() + { + "BadMethodCallException" + } else if e + .downcast_ref::<shirabe_php_shim::OutOfBoundsException>() + .is_some() + { + "OutOfBoundsException" + } else if e + .downcast_ref::<shirabe_php_shim::ErrorException>() + .is_some() + { + "ErrorException" + } else if e + .downcast_ref::<shirabe_php_shim::PharException>() + .is_some() + { + "PharException" + } else { + "Exception" + }; + name.to_string() +} + /// Helper mirroring PHP's `$e instanceof CommandNotFoundException`. fn downcast_command_not_found(e: &anyhow::Error) -> Option<&CommandNotFoundException> { if let Some(cnf) = e.downcast_ref::<CommandNotFoundException>() { diff --git a/crates/shirabe/tests/cli.rs b/crates/shirabe/tests/cli.rs index 5fd3980..c0fa1d9 100644 --- a/crates/shirabe/tests/cli.rs +++ b/crates/shirabe/tests/cli.rs @@ -132,18 +132,15 @@ run_no_panic_tests! { run_check_platform_reqs => "check-platform-reqs", #[ignore = "currently panics"] run_clear_cache => "clear-cache", - #[ignore = "currently panics"] run_config => "config", #[ignore = "currently panics"] run_create_project => "create-project", - #[ignore = "currently panics"] run_depends => "depends", #[ignore = "currently panics"] run_diagnose => "diagnose", run_dump_autoload => "dump-autoload", run_exec => "exec", run_fund => "fund", - #[ignore = "currently panics"] run_global => "global", #[ignore = "currently panics"] run_init => "init", @@ -151,12 +148,9 @@ run_no_panic_tests! { run_licenses => "licenses", #[ignore = "currently panics"] run_outdated => "outdated", - #[ignore = "currently panics"] run_prohibits => "prohibits", run_reinstall => "reinstall", - #[ignore = "currently panics"] run_remove => "remove", - #[ignore = "currently panics"] run_repository => "repository", #[ignore = "currently panics"] run_require => "require", |
