diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-21 20:36:17 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-21 21:05:13 +0900 |
| commit | 40a9b8c4e5f80b1e4dc8df8f5af1b7b4903e69df (patch) | |
| tree | 864c57092e26db1c35cb1952147da138da0893f9 /crates/shirabe-php-shim | |
| parent | e283399a0304846bbff8484f455c50ecb3de7048 (diff) | |
| download | php-shirabe-40a9b8c4e5f80b1e4dc8df8f5af1b7b4903e69df.tar.gz php-shirabe-40a9b8c4e5f80b1e4dc8df8f5af1b7b4903e69df.tar.zst php-shirabe-40a9b8c4e5f80b1e4dc8df8f5af1b7b4903e69df.zip | |
feat(php-shim): implement serialize/var_export/to_array/get_debug_type
Implement var.rs shims: serialize() (scalars/arrays with canonical
integer-key normalization), var_export()/var_export_str() (PHP-faithful
indentation and quoting), to_array() ((array) cast semantics),
get_debug_type() and is_iterable() for the scalar/array cases.
Reflection-dependent helpers (is_a, is_callable, get_class, instance_of,
etc.) and object variants remain TODO(phase-d) as they need runtime
class information PhpMixed does not carry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-shim')
| -rw-r--r-- | crates/shirabe-php-shim/src/var.rs | 246 |
1 files changed, 234 insertions, 12 deletions
diff --git a/crates/shirabe-php-shim/src/var.rs b/crates/shirabe-php-shim/src/var.rs index b4dcf21..0dc7523 100644 --- a/crates/shirabe-php-shim/src/var.rs +++ b/crates/shirabe-php-shim/src/var.rs @@ -14,8 +14,83 @@ pub fn empty(value: &PhpMixed) -> bool { } } -pub fn serialize(_value: &PhpMixed) -> String { - todo!() +pub fn serialize(value: &PhpMixed) -> String { + let mut out = String::new(); + serialize_into(&mut out, value); + out +} + +fn serialize_into(out: &mut String, value: &PhpMixed) { + match value { + PhpMixed::Null => out.push_str("N;"), + PhpMixed::Bool(b) => { + out.push_str("b:"); + out.push(if *b { '1' } else { '0' }); + out.push(';'); + } + PhpMixed::Int(i) => out.push_str(&format!("i:{};", i)), + PhpMixed::Float(f) => out.push_str(&format!("d:{};", serialize_float(*f))), + // PHP measures the string length in bytes. + PhpMixed::String(s) => out.push_str(&format!("s:{}:\"{}\";", s.len(), s)), + PhpMixed::List(items) => { + out.push_str(&format!("a:{}:{{", items.len())); + for (i, item) in items.iter().enumerate() { + out.push_str(&format!("i:{};", i)); + serialize_into(out, item); + } + out.push('}'); + } + PhpMixed::Array(entries) => { + out.push_str(&format!("a:{}:{{", entries.len())); + for (k, v) in entries { + // PHP normalizes canonical integer string keys to integer keys. + match canonical_int_key(k) { + Some(i) => out.push_str(&format!("i:{};", i)), + None => out.push_str(&format!("s:{}:\"{}\";", k.len(), k)), + } + serialize_into(out, v); + } + out.push('}'); + } + // TODO(phase-d): object serialization needs the PHP class name and the property + // visibility name-mangling ("O:len:\"Class\":n:{...}"), which PhpMixed::Object does not + // carry. + PhpMixed::Object(_) => todo!(), + } +} + +// TODO(phase-d): PHP's serialize uses serialize_precision (-1 => shortest round-trip), which Rust's +// default float formatting also produces, but the two differ on scientific-notation spelling (PHP +// "1.0E+20" vs Rust "1e20") for very large/small magnitudes. +fn serialize_float(f: f64) -> String { + if f.is_nan() { + "NAN".to_string() + } else if f.is_infinite() { + if f < 0.0 { + "-INF".to_string() + } else { + "INF".to_string() + } + } else { + format!("{}", f) + } +} + +/// Returns the integer a PHP array key string normalizes to, or None if the key stays a string. +/// PHP treats a key as an integer only when it is a canonical decimal integer: no leading `+`, no +/// redundant leading zeros, and within the platform integer range ("-0" is not canonical). +fn canonical_int_key(key: &str) -> Option<i64> { + if key == "0" { + return Some(0); + } + let digits = key.strip_prefix('-').unwrap_or(key); + if digits.is_empty() || digits.starts_with('0') { + return None; + } + if !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + key.parse::<i64>().ok() } pub fn is_bool(_value: &PhpMixed) -> bool { @@ -46,6 +121,9 @@ pub fn is_numeric(value: &PhpMixed) -> bool { } pub fn is_callable(_value: &PhpMixed) -> bool { + // TODO(phase-d): PHP is_callable() checks whether the value names an existing function/method or + // is a closure. PhpMixed has no callable variant and the shim has no function registry, so + // callability cannot be determined. todo!() } @@ -54,6 +132,8 @@ pub fn is_object(_value: &PhpMixed) -> bool { } pub fn is_a(_object_or_class: &PhpMixed, _class: &str, _allow_string: bool) -> bool { + // TODO(phase-d): requires runtime class information (the object's class and its ancestry), which + // PhpMixed::Object does not carry. todo!() } @@ -70,8 +150,11 @@ pub fn is_null(_value: &PhpMixed) -> bool { matches!(_value, PhpMixed::Null) } -pub fn is_iterable(_value: &PhpMixed) -> bool { - todo!() +pub fn is_iterable(value: &PhpMixed) -> bool { + // PHP is_iterable() is true for arrays and Traversable objects. + // TODO(phase-d): PhpMixed::Object cannot report whether it implements Traversable, so an + // iterable object is conservatively treated as non-iterable here. + matches!(value, PhpMixed::List(_) | PhpMixed::Array(_)) } pub fn is_numeric_string(s: &str) -> bool { @@ -104,19 +187,26 @@ pub fn is_numeric_to_int(value: &PhpMixed) -> i64 { } pub fn instance_of<T>(_value: &PhpMixed) -> bool { + // TODO(phase-d): PHP `instanceof` needs the runtime class of the value, which PhpMixed::Object + // does not carry. todo!() } pub fn is_subclass_of(_object_or_class: &PhpMixed, _class_name: &str, _allow_string: bool) -> bool { + // TODO(phase-d): requires runtime class ancestry, which PhpMixed::Object does not carry. todo!() } pub fn get_class(_object: &PhpMixed) -> String { + // TODO(phase-d): PhpMixed::Object carries no class name; there is no runtime class to report. todo!() } // Overload accepting an `anyhow::Error` (PHP's `get_class($e)` is commonly used on exceptions). pub fn get_class_err(_e: &anyhow::Error) -> String { + // TODO(phase-d): PHP returns the exception's class name. anyhow::Error carries the concrete + // exception type, but mapping each ported exception struct to its PHP class name is not yet + // wired up (cf. php_exception_get_code which downcasts case by case). todo!() } @@ -124,11 +214,22 @@ pub fn get_class_err(_e: &anyhow::Error) -> String { /// class name; in Rust we don't have a runtime class name, so this stub is left /// as `todo!()`. pub fn get_class_obj<T: ?Sized>(_object: &T) -> String { + // TODO(phase-d): PHP returns the object's class name; Rust has no runtime class name for an + // arbitrary `T` (the static type path is not the PHP class name). todo!() } -pub fn get_debug_type(_value: &PhpMixed) -> String { - todo!() +pub fn get_debug_type(value: &PhpMixed) -> String { + match value { + PhpMixed::Null => "null".to_string(), + PhpMixed::Bool(_) => "bool".to_string(), + PhpMixed::Int(_) => "int".to_string(), + PhpMixed::Float(_) => "float".to_string(), + PhpMixed::String(_) => "string".to_string(), + PhpMixed::List(_) | PhpMixed::Array(_) => "array".to_string(), + // TODO(phase-d): PHP returns the object's class name; PhpMixed::Object carries none. + PhpMixed::Object(_) => todo!(), + } } pub fn get_debug_type_obj<T>(_value: &T) -> String { @@ -138,6 +239,8 @@ pub fn get_debug_type_obj<T>(_value: &T) -> String { } pub fn instantiate_class(_class: &str, _args: Vec<PhpMixed>) -> PhpMixed { + // TODO(phase-d): instantiating a class by name needs a runtime class registry (reflection), + // which the shim does not provide. todo!() } @@ -151,6 +254,8 @@ pub fn php_to_string(value: &PhpMixed) -> String { PhpMixed::String(s) => s.clone(), // PHP renders any array as the literal string "Array". PhpMixed::List(_) | PhpMixed::Array(_) => "Array".to_string(), + // TODO(phase-d): PHP casts an object to string via its __toString() method; PhpMixed::Object + // carries no class/method information to dispatch to. PhpMixed::Object(_) => todo!(), } } @@ -213,8 +318,23 @@ pub fn intval(_value: &PhpMixed) -> i64 { } } -pub fn to_array(_value: PhpMixed) -> IndexMap<String, PhpMixed> { - todo!() +pub fn to_array(value: PhpMixed) -> IndexMap<String, PhpMixed> { + // PHP `(array)` cast: null => empty array; scalar => [0 => value]; list keeps its integer keys; + // array/object map directly to their entries. + match value { + PhpMixed::Null => IndexMap::new(), + PhpMixed::Array(m) | PhpMixed::Object(m) => m, + PhpMixed::List(items) => items + .into_iter() + .enumerate() + .map(|(i, v)| (i.to_string(), v)) + .collect(), + scalar => { + let mut m = IndexMap::new(); + m.insert("0".to_string(), scalar); + m + } + } } pub fn to_string(value: &PhpMixed) -> String { @@ -244,10 +364,112 @@ pub fn boolval(value: &PhpMixed) -> bool { php_truthy(value) } -pub fn var_export(_value: &PhpMixed, _return: bool) -> String { - todo!() +pub fn var_export(value: &PhpMixed, r#return: bool) -> String { + let mut out = String::new(); + var_export_into(&mut out, value, 0); + if r#return { + out + } else { + // PHP echoes the representation and returns null when $return is false. + print!("{}", out); + String::new() + } } -pub fn var_export_str(_value: &str, _return: bool) -> String { - todo!() +pub fn var_export_str(value: &str, r#return: bool) -> String { + let out = var_export_string(value); + if r#return { + out + } else { + print!("{}", out); + String::new() + } +} + +fn var_export_into(out: &mut String, value: &PhpMixed, level: usize) { + match value { + PhpMixed::Null => out.push_str("NULL"), + PhpMixed::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + PhpMixed::Int(i) => out.push_str(&i.to_string()), + PhpMixed::Float(f) => out.push_str(&var_export_float(*f)), + PhpMixed::String(s) => out.push_str(&var_export_string(s)), + PhpMixed::List(items) => { + out.push_str("array (\n"); + for (i, item) in items.iter().enumerate() { + var_export_indent(out, level + 1); + out.push_str(&format!("{} => ", i)); + if matches!(item, PhpMixed::List(_) | PhpMixed::Array(_)) { + out.push('\n'); + var_export_indent(out, level + 1); + } + var_export_into(out, item, level + 1); + out.push_str(",\n"); + } + var_export_indent(out, level); + out.push(')'); + } + PhpMixed::Array(entries) => { + out.push_str("array (\n"); + for (k, v) in entries { + var_export_indent(out, level + 1); + match canonical_int_key(k) { + Some(i) => out.push_str(&i.to_string()), + None => out.push_str(&var_export_string(k)), + } + out.push_str(" => "); + if matches!(v, PhpMixed::List(_) | PhpMixed::Array(_)) { + out.push('\n'); + var_export_indent(out, level + 1); + } + var_export_into(out, v, level + 1); + out.push_str(",\n"); + } + var_export_indent(out, level); + out.push(')'); + } + // TODO(phase-d): PHP renders objects as "\Class::__set_state(array(...))"; PhpMixed::Object + // carries no class name. + PhpMixed::Object(_) => todo!(), + } +} + +fn var_export_indent(out: &mut String, level: usize) { + for _ in 0..level { + out.push_str(" "); + } +} + +// PHP var_export() escapes only the backslash and single-quote inside the single-quoted literal. +fn var_export_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if c == '\'' || c == '\\' { + out.push('\\'); + } + out.push(c); + } + out.push('\''); + out +} + +fn var_export_float(f: f64) -> String { + if f.is_nan() { + return "NAN".to_string(); + } + if f.is_infinite() { + return if f < 0.0 { + "-INF".to_string() + } else { + "INF".to_string() + }; + } + // PHP always renders a float with a fractional/exponent marker, so a whole-valued float gets a + // trailing ".0". + let s = format!("{}", f); + if s.contains('.') || s.contains('e') || s.contains('E') { + s + } else { + format!("{}.0", s) + } } |
