Commit 29665a6f04a for php
commit 29665a6f04a5bd8882a26e7eaa6327f29b5d7ae4
Author: Jorg Sowa <jorg.sowa@gmail.com>
Date: Thu Nov 5 22:06:01 2026 +0100
Fix AST export dropping braces of dynamic class constant fetches
Closes GH-25221
diff --git a/NEWS b/NEWS
index 6fde7abcbc4..3c5ffd7802b 100644
--- a/NEWS
+++ b/NEWS
@@ -6,6 +6,8 @@ PHP NEWS
. Fixed AST printing of interface extends list as implements. (Jorg Sowa)
. Fixed AST printing dropping fully qualified and namespace-relative prefixes
of class names in implements, catch, and trait use lists. (Jorg Sowa)
+ . Fixed AST export dropping the braces of dynamic class constant fetches.
+ (Jorg Sowa)
- Curl:
. Fixed curl_reset() retaining CURLOPT_PRIVATE after reset. (lacatoire)
diff --git a/Zend/tests/assert/ast_export_dynamic_class_const.phpt b/Zend/tests/assert/ast_export_dynamic_class_const.phpt
new file mode 100644
index 00000000000..43a5e31c714
--- /dev/null
+++ b/Zend/tests/assert/ast_export_dynamic_class_const.phpt
@@ -0,0 +1,50 @@
+--TEST--
+AST export of dynamic class constant fetches
+--INI--
+zend.assertions=1
+--FILE--
+<?php
+
+$tests = [
+ fn() => assert(false && C::FOO),
+ fn() => assert(false && C::list),
+ fn() => assert(false && C::class),
+ fn() => assert(false && C::{$x}),
+ fn() => assert(false && C::{FOO}),
+ fn() => assert(false && C::{foo()}),
+ fn() => assert(false && C::{$a . $b}),
+ fn() => assert(false && C::{1}),
+ fn() => assert(false && C::{'foo'}),
+ fn() => assert(false && C::{'a b'}),
+ fn() => assert(false && C::{''}),
+ fn() => assert(false && C::{'class'}),
+ fn() => assert(false && C::{'CLASS'}),
+ fn() => assert(false && $o::{$x}),
+ fn() => assert(false && static::{$x}),
+];
+
+foreach ($tests as $test) {
+ try {
+ $test();
+ } catch (AssertionError $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+?>
+--EXPECT--
+AssertionError: assert(false && C::FOO)
+AssertionError: assert(false && C::list)
+AssertionError: assert(false && C::class)
+AssertionError: assert(false && C::{$x})
+AssertionError: assert(false && C::{FOO})
+AssertionError: assert(false && C::{foo()})
+AssertionError: assert(false && C::{$a . $b})
+AssertionError: assert(false && C::{1})
+AssertionError: assert(false && C::foo)
+AssertionError: assert(false && C::{'a b'})
+AssertionError: assert(false && C::{''})
+AssertionError: assert(false && C::{'class'})
+AssertionError: assert(false && C::{'CLASS'})
+AssertionError: assert(false && $o::{$x})
+AssertionError: assert(false && static::{$x})
diff --git a/Zend/zend_ast.c b/Zend/zend_ast.c
index dc40c15fc45..7d6038a194d 100644
--- a/Zend/zend_ast.c
+++ b/Zend/zend_ast.c
@@ -1429,6 +1429,24 @@ static ZEND_COLD bool zend_ast_var_needs_braces(char ch)
return ch == '[' || zend_ast_valid_var_char(ch);
}
+static ZEND_COLD void zend_ast_export_class_const_name(smart_str *str, zend_ast *ast, int indent)
+{
+ if (ast->kind == ZEND_AST_ZVAL) {
+ const zval *zv = zend_ast_get_zval(ast);
+
+ /* A bare "class" would turn the dynamic fetch into a class name fetch. */
+ if (Z_TYPE_P(zv) == IS_STRING
+ && zend_ast_valid_var_name(Z_STRVAL_P(zv), Z_STRLEN_P(zv))
+ && !zend_string_equals_ci(Z_STR_P(zv), ZSTR_KNOWN(ZEND_STR_CLASS))) {
+ smart_str_append(str, Z_STR_P(zv));
+ return;
+ }
+ }
+ smart_str_appendc(str, '{');
+ zend_ast_export_ex(str, ast, 0, indent);
+ smart_str_appendc(str, '}');
+}
+
static ZEND_COLD void zend_ast_export_var(smart_str *str, zend_ast *ast, int priority, int indent)
{
if (ast->kind == ZEND_AST_ZVAL) {
@@ -2237,7 +2255,7 @@ static ZEND_COLD void zend_ast_export_ex(smart_str *str, zend_ast *ast, int prio
case ZEND_AST_CLASS_CONST:
zend_ast_export_ns_name(str, ast->child[0], 0, indent);
smart_str_appends(str, "::");
- zend_ast_export_name(str, ast->child[1], 0, indent);
+ zend_ast_export_class_const_name(str, ast->child[1], indent);
break;
case ZEND_AST_CLASS_NAME:
if (ast->child[0] == NULL) {