use crate::PhpMixed; use indexmap::IndexMap; pub fn empty(value: &PhpMixed) -> bool { match value { PhpMixed::Null => true, PhpMixed::Bool(b) => !*b, PhpMixed::Int(i) => *i == 0, PhpMixed::Float(f) => *f == 0.0, PhpMixed::String(s) => s.is_empty() || s == "0", PhpMixed::List(v) => v.is_empty(), PhpMixed::Array(m) => m.is_empty(), PhpMixed::Object(_) => false, } } 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(php-runtime): 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(php-runtime): the shim has no ini registry, so this hard-codes serialize_precision=-1 // (the default: the shortest round-trip representation). Any other serialize_precision selects // php_gcvt's fixed-precision mode, which is not reachable from here. fn serialize_float(f: f64) -> String { let mut out = String::new(); shirabe_php_src::zend::zend_smart_str::smart_str_append_double(&mut out, f, -1, false); out } /// 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). pub fn canonical_int_key(key: &str) -> Option { 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::().ok() } pub fn is_bool(value: &PhpMixed) -> bool { matches!(value, PhpMixed::Bool(_)) } pub fn is_string(value: &PhpMixed) -> bool { matches!(value, PhpMixed::String(_)) } pub fn is_int(value: &PhpMixed) -> bool { matches!(value, PhpMixed::Int(_)) } pub fn is_scalar(value: &PhpMixed) -> bool { matches!( value, PhpMixed::Bool(_) | PhpMixed::Int(_) | PhpMixed::Float(_) | PhpMixed::String(_) ) } pub fn is_numeric(value: &PhpMixed) -> bool { match value { PhpMixed::Int(_) | PhpMixed::Float(_) => true, PhpMixed::String(s) => is_numeric_string(s), _ => false, } } pub fn is_callable(value: &PhpMixed) -> bool { match value { // Scalars and null are never callable in PHP. PhpMixed::Null | PhpMixed::Bool(_) | PhpMixed::Int(_) | PhpMixed::Float(_) => false, // TODO(php-runtime): PHP is_callable() checks whether a string names an existing function, or an // array/object resolves to a method/__invoke. PhpMixed has no callable variant and the shim // has no function/method registry, so callability of these cannot be determined. _ => todo!(), } } pub fn is_object(value: &PhpMixed) -> bool { matches!(value, PhpMixed::Object(_)) } pub fn is_a(_object_or_class: &PhpMixed, _class: &str, _allow_string: bool) -> bool { // TODO(php-runtime): requires runtime class information (the object's class and its ancestry), which // PhpMixed::Object does not carry. todo!() } pub fn is_array(value: &PhpMixed) -> bool { matches!(value, PhpMixed::List(_) | PhpMixed::Array(_)) } pub fn is_null(value: &PhpMixed) -> bool { matches!(value, PhpMixed::Null) } pub fn is_iterable(value: &PhpMixed) -> bool { // PHP is_iterable() is true for arrays and Traversable objects. // TODO(php-runtime): 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 { // PHP is_numeric() on a string: optional leading whitespace, an integer or float literal // (decimal/scientific). PHP does not treat "inf"/"nan" as numeric. let trimmed = s.trim(); if trimmed.is_empty() { return false; } let lower = trimmed.to_ascii_lowercase(); if lower.contains("inf") || lower.contains("nan") { return false; } trimmed.parse::().is_ok() || trimmed.parse::().is_ok() } pub fn is_numeric_to_int(value: &PhpMixed) -> i64 { // PHP: is_numeric($value) ? (int) $value : 0. match value { PhpMixed::Int(n) => *n, PhpMixed::Float(f) => *f as i64, PhpMixed::String(s) if is_numeric_string(s) => { let trimmed = s.trim(); trimmed .parse::() .unwrap_or_else(|_| trimmed.parse::().map(|f| f as i64).unwrap_or(0)) } _ => 0, } } /// Approximates PHP's `<=>` for two strings: if both are numeric strings, compare numerically /// (as PHP does), otherwise fall back to a byte-wise comparison. /// /// TODO(php-semantics): this only covers the string/string case of PHP's loose comparison. PHP's `<=>` has many /// more special-cased rules across other operand type combinations (bool, array, null, object, /// numeric-string-vs-non-numeric-string, ...). Extend this if a new caller needs those. pub fn loosely_compare(a: &str, b: &str) -> std::cmp::Ordering { if is_numeric_string(a) && is_numeric_string(b) { match (a.trim().parse::(), b.trim().parse::()) { (Ok(na), Ok(nb)) => na.cmp(&nb), _ => { let na: f64 = a.trim().parse().unwrap_or(0.0); let nb: f64 = b.trim().parse().unwrap_or(0.0); na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal) } } } else { a.cmp(b) } } pub fn get_class(_object: &PhpMixed) -> String { // TODO(php-runtime): PhpMixed::Object carries no class name; there is no runtime class to report. 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(php-runtime): PHP returns the object's class name; PhpMixed::Object carries none. PhpMixed::Object(_) => todo!(), } } pub fn get_debug_type_obj(_value: &T) -> String { // PHP get_debug_type() returns the class name for an object. Rust has no runtime class names; // the static type name is the closest faithful diagnostic available here. std::any::type_name::().to_string() } pub fn php_to_string(value: &PhpMixed) -> String { match value { PhpMixed::Null => String::new(), PhpMixed::Bool(true) => "1".to_string(), PhpMixed::Bool(false) => String::new(), PhpMixed::Int(i) => i.to_string(), PhpMixed::Float(f) => float_to_string(*f), PhpMixed::String(s) => s.clone(), // PHP renders any array as the literal string "Array". PhpMixed::List(_) | PhpMixed::Array(_) => "Array".to_string(), // TODO(php-runtime): PHP casts an object to string via its __toString() method; PhpMixed::Object // carries no class/method information to dispatch to. PhpMixed::Object(_) => todo!(), } } // TODO(php-runtime): the shim has no ini registry, so this hard-codes precision=14, the default // significant-digit count a float-to-string cast rounds to. fn float_to_string(f: f64) -> String { let mut out = String::new(); shirabe_php_src::zend::zend_smart_str::smart_str_append_double(&mut out, f, 14, false); out } pub fn strval(value: &PhpMixed) -> String { php_to_string(value) } pub fn intval(value: &PhpMixed) -> i64 { // 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, } } pub fn to_array(value: PhpMixed) -> IndexMap { // 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 { php_to_string(value) } pub fn to_bool(value: &PhpMixed) -> bool { php_truthy(value) } pub fn php_truthy(value: &PhpMixed) -> bool { match value { PhpMixed::Null => false, PhpMixed::Bool(b) => *b, PhpMixed::Int(i) => *i != 0, PhpMixed::Float(f) => *f != 0.0, // PHP treats only "" and "0" as falsy strings. PhpMixed::String(s) => !s.is_empty() && s != "0", PhpMixed::List(items) => !items.is_empty(), PhpMixed::Array(entries) => !entries.is_empty(), // Objects are always truthy. PhpMixed::Object(_) => true, } } pub fn boolval(value: &PhpMixed) -> bool { php_truthy(value) } 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, 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(_) | PhpMixed::Object(_) ) { 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(_) | PhpMixed::Object(_) ) { 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(php-runtime): PhpMixed::Object carries no class name, so this renders the // stdClass shape "(object) array(...)" (PHP 8.5 oracle); any other class would render // as "\Class::__set_state(array(...))" and cannot be distinguished here. PhpMixed::Object(entries) => { out.push_str("(object) array(\n"); for (k, v) in entries { var_export_indent(out, level + 1); // Object property keys are always exported as quoted strings, never as ints. out.push(' '); out.push_str(&var_export_string(k)); out.push_str(" => "); if matches!( v, PhpMixed::List(_) | PhpMixed::Array(_) | PhpMixed::Object(_) ) { 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(')'); } } } 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() { // PHP var_export breaks NUL bytes out of the single-quoted literal as `' . "\0" . '`. if c == '\0' { out.push_str("' . \"\\0\" . '"); continue; } if c == '\'' || c == '\\' { out.push('\\'); } out.push(c); } out.push('\''); out } // TODO(php-runtime): the shim has no ini registry, so this hard-codes serialize_precision=-1 // (the default: the shortest round-trip representation). Any other serialize_precision selects // php_gcvt's fixed-precision mode, which is not reachable from here. fn var_export_float(f: f64) -> String { let mut out = String::new(); // var_export() renders a whole-valued float with a trailing ".0" so that it stays a float when // the exported code is evaluated. shirabe_php_src::zend::zend_smart_str::smart_str_append_double(&mut out, f, -1, true); out } #[cfg(test)] mod tests { use super::*; /// PHP 8.5.9 oracle: `(string) $v` with the default precision=14. The digits are rounded to 14 /// significant places and the layout switches to exponent form once the decimal exponent /// leaves `-4..=13`, both of which differ from `serialize()`. #[test] #[allow(clippy::excessive_precision)] fn test_php_to_string_float() { for (expected, value) in [ ("0", 0.0), ("-0", -0.0), ("1", 1.0), ("-1", -1.0), ("100", 100.0), ("0.5", 0.5), ("-1.5", -1.5), ("0.1", 0.1), ("0.33333333333333", 1.0 / 3.0), ("0.14285714285714", 1.0 / 7.0), ("0.3", 0.30000000000000004), ("0.001", 1e-3), ("0.0001", 1e-4), ("1.0E-5", 1e-5), ("-1.0E-5", -1e-5), ("0.00012345", 0.00012345), ("1.23456789E-5", 1.23456789e-5), ("10000000000000", 1e13), ("1.0E+14", 1e14), ("1.0E+15", 1e15), ("1.0E+16", 1e16), ("1.0E+17", 1e17), ("12345678901234", 12345678901234.5), ("1.2345678901235E+14", 123456789012345.6), ("1.2345678901235E+16", 1.2345678901234568e16), ("1.0E+20", 1e20), ("1.0E+100", 1e100), ("1.0E-100", 1e-100), ("1.7976931348623E+308", f64::MAX), ("-1.7976931348623E+308", f64::MIN), ("2.2250738585072E-308", f64::MIN_POSITIVE), ("2.2204460492503E-16", f64::EPSILON), ("4.9406564584125E-324", 5e-324), ("2.4703282292062E-323", 2.5e-323), ("NAN", f64::NAN), ("INF", f64::INFINITY), ("-INF", f64::NEG_INFINITY), ] { assert_eq!(expected, php_to_string(&PhpMixed::Float(value))); } } /// PHP 8.5.9 oracle: `serialize($v)` with the default serialize_precision=-1. The shortest /// round-trip digits are laid out plainly while the decimal exponent stays in `-4..=16`, and /// switch to `.E` outside it, where a single-digit mantissa gains a `.0` /// and the exponent is unpadded and always signed. #[test] fn test_serialize_float() { for (expected, value) in [ ("d:0;", 0.0), ("d:-0;", -0.0), ("d:1;", 1.0), ("d:-1;", -1.0), ("d:100;", 100.0), ("d:0.5;", 0.5), ("d:-1.5;", -1.5), ("d:0.1;", 0.1), ("d:0.3333333333333333;", 1.0 / 3.0), ("d:0.001;", 1e-3), ("d:0.0001;", 1e-4), ("d:1.0E-5;", 1e-5), ("d:-1.0E-5;", -1e-5), ("d:0.00012345;", 0.00012345), ("d:1.23456789E-5;", 1.23456789e-5), ("d:1000000000000000;", 1e15), ("d:10000000000000000;", 1e16), ("d:12345678901234568;", 1.2345678901234568e16), ("d:1.0E+17;", 1e17), ("d:-1.0E+17;", -1e17), ("d:1.5E+17;", 1.5e17), ("d:1.0E+20;", 1e20), ("d:1.0E+100;", 1e100), ("d:1.0E-100;", 1e-100), ("d:1.7976931348623157E+308;", f64::MAX), ("d:-1.7976931348623157E+308;", f64::MIN), ("d:2.2250738585072014E-308;", f64::MIN_POSITIVE), ("d:2.220446049250313E-16;", f64::EPSILON), ("d:5.0E-324;", 5e-324), ("d:2.5E-323;", 2.5e-323), ("d:NAN;", f64::NAN), ("d:INF;", f64::INFINITY), ("d:-INF;", f64::NEG_INFINITY), ] { assert_eq!(expected, serialize(&PhpMixed::Float(value))); } } /// PHP 8.5.9 oracle: `var_export($v, true)` with the default serialize_precision=-1. The /// digits follow `serialize()`, except that a value laid out plainly and lacking a fractional /// part gains a ".0"; one in exponent form already carries a period and gains nothing. #[test] fn test_var_export_float() { for (expected, value) in [ ("0.0", 0.0), ("-0.0", -0.0), ("1.0", 1.0), ("-1.0", -1.0), ("100.0", 100.0), ("0.5", 0.5), ("-1.5", -1.5), ("0.1", 0.1), ("0.3333333333333333", 1.0 / 3.0), ("0.001", 1e-3), ("0.0001", 1e-4), ("1.0E-5", 1e-5), ("-1.0E-5", -1e-5), ("0.00012345", 0.00012345), ("1.23456789E-5", 1.23456789e-5), ("1000000000000000.0", 1e15), ("10000000000000000.0", 1e16), ("12345678901234568.0", 1.2345678901234568e16), ("1.0E+17", 1e17), ("-1.0E+17", -1e17), ("1.5E+17", 1.5e17), ("1.0E+20", 1e20), ("1.0E+100", 1e100), ("1.0E-100", 1e-100), ("1.7976931348623157E+308", f64::MAX), ("-1.7976931348623157E+308", f64::MIN), ("2.2250738585072014E-308", f64::MIN_POSITIVE), ("2.220446049250313E-16", f64::EPSILON), ("5.0E-324", 5e-324), ("2.5E-323", 2.5e-323), ("NAN", f64::NAN), ("INF", f64::INFINITY), ("-INF", f64::NEG_INFINITY), ] { assert_eq!(expected, var_export(&PhpMixed::Float(value), true)); } } /// PHP 8.5.8 oracle: `var_export($v, true)` over stdClass-shaped objects, standalone and /// nested in arrays (object properties indent one space deeper than array elements, and /// keys stay quoted strings). #[test] fn test_var_export_object() { assert_eq!( "(object) array(\n)", var_export(&PhpMixed::Object(IndexMap::new()), true) ); let obj = PhpMixed::Object(IndexMap::from([ ("a".to_string(), PhpMixed::Int(1)), ( "b".to_string(), PhpMixed::List(vec![PhpMixed::Int(1), PhpMixed::Int(2)]), ), ])); assert_eq!( "(object) array(\n 'a' => 1,\n 'b' => \n array (\n 0 => 1,\n 1 => 2,\n ),\n)", var_export(&obj, true) ); let arr = PhpMixed::Array(IndexMap::from([( "x".to_string(), PhpMixed::Object(IndexMap::from([ ("a".to_string(), PhpMixed::Int(1)), ( "o".to_string(), PhpMixed::Object(IndexMap::from([("b".to_string(), PhpMixed::Int(2))])), ), ])), )])); assert_eq!( "array (\n 'x' => \n (object) array(\n 'a' => 1,\n 'o' => \n (object) array(\n 'b' => 2,\n ),\n ),\n)", var_export(&arr, true) ); } }