aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-20 19:24:00 +0900
committernsfisis <nsfisis@gmail.com>2026-06-20 19:25:31 +0900
commit1f4addc0c016eaf0fd953fd10995d481ea7db74b (patch)
treed33c8aa8800c0dff87652391f5297bb3293a42fb
parent81b9fc9d92bb74aa8428ae4db39bd84e8c16095c (diff)
downloadphp-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.rs167
-rw-r--r--crates/shirabe/src/console/application.rs215
-rw-r--r--crates/shirabe/tests/cli.rs6
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",