aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-16 16:24:56 +0900
committernsfisis <nsfisis@gmail.com>2026-08-16 16:47:52 +0900
commit5e4bd47bf92e2fe151e3905ee8ab9972c9f70f8d (patch)
tree443ba0ba88d91fc5e84c9e1554d2d0dc78b6633d
parentd8992fd631ed66b85e784e7dd3fca77ce979c00a (diff)
downloadphp-shirabe-5e4bd47bf92e2fe151e3905ee8ab9972c9f70f8d.tar.gz
php-shirabe-5e4bd47bf92e2fe151e3905ee8ab9972c9f70f8d.tar.zst
php-shirabe-5e4bd47bf92e2fe151e3905ee8ab9972c9f70f8d.zip
fix(php-shim): render floats through php_gcvt
serialize(), var_export() and the string cast each hand-rolled `format!("{}", f)`, which never emits PHP's exponential spelling and carries the wrong precision for the string cast. PHP writes 1.0E+20 where Rust writes 1e20, and (string) 1/3 is 0.33333333333333, not the shortest round-trip form. Delegate all three to smart_str_append_double, which shirabe-php-src already provides and shirabe-php-rpc's codec already used: precision -1 for serialize() and var_export(), 14 for the string cast. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/shirabe-php-rpc/tests/oracle.rs19
-rw-r--r--crates/shirabe-php-shim/src/var.rs194
2 files changed, 180 insertions, 33 deletions
diff --git a/crates/shirabe-php-rpc/tests/oracle.rs b/crates/shirabe-php-rpc/tests/oracle.rs
index b98f2203..2785d715 100644
--- a/crates/shirabe-php-rpc/tests/oracle.rs
+++ b/crates/shirabe-php-rpc/tests/oracle.rs
@@ -73,6 +73,21 @@ fn encode_direction_matches_php_for_scalars_and_floats() {
5e-324,
1.0 / 3.0,
0.30000000000000004,
+ // The two edges of the plain-notation window, approached from both sides: PHP keeps a
+ // decimal exponent of -4..=16 in plain notation whatever the digit count, and the widest
+ // mantissa an f64 has does not push the large edge outwards.
+ 1e-3,
+ -1e-5,
+ 1.23456789e-5,
+ 0.00012345,
+ 1.2345678901234568e16,
+ 1.2345678901234567e19,
+ -1e17,
+ 1.5e17,
+ f64::MIN,
+ f64::MIN_POSITIVE,
+ f64::EPSILON,
+ 2.5e-323,
f64::NAN,
f64::INFINITY,
f64::NEG_INFINITY,
@@ -226,6 +241,10 @@ fn decode_direction_matches_php_serialize_output() {
"return serialize([\"\\xff\\x00key\" => \"\\x80\\x81\", 0 => \"plain\"]);",
// Floats straight from the PHP formatter.
r#"return serialize([0.1, 2.0, 1e17, 1e-5, -0.0, NAN, INF, -INF, 1/3]);"#,
+ // Both edges of PHP's plain-notation window, plus the extremes of the f64 range.
+ r#"return serialize([1e-4, 1e-5, 1e16, 1e17, 1.2345678901234568e16, -1e17,
+ PHP_FLOAT_MAX, -PHP_FLOAT_MAX, PHP_FLOAT_MIN, PHP_FLOAT_EPSILON,
+ 5e-324, 2.5e-323]);"#,
// A sparse int-keyed array (not a list).
r#"return serialize([3 => "c", 1 => "a"]);"#,
// Deep nesting built in a loop.
diff --git a/crates/shirabe-php-shim/src/var.rs b/crates/shirabe-php-shim/src/var.rs
index 79704df1..71e94c25 100644
--- a/crates/shirabe-php-shim/src/var.rs
+++ b/crates/shirabe-php-shim/src/var.rs
@@ -59,21 +59,13 @@ fn serialize_into(out: &mut String, value: &PhpMixed) {
}
}
-// TODO(php-semantics): 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.
+// 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 {
- 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)
- }
+ 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.
@@ -236,7 +228,7 @@ pub fn php_to_string(value: &PhpMixed) -> String {
PhpMixed::Bool(true) => "1".to_string(),
PhpMixed::Bool(false) => String::new(),
PhpMixed::Int(i) => i.to_string(),
- PhpMixed::Float(f) => f.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(),
@@ -246,6 +238,14 @@ pub fn php_to_string(value: &PhpMixed) -> String {
}
}
+// 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)
}
@@ -471,31 +471,159 @@ fn var_export_string(s: &str) -> String {
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 {
- 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)
- }
+ 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 `<d>.<rest>E<sign><exp>` 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).