[PHP-CVS] [php-src] master: php_printf: introduce %pS to replace custom specifier %S (#22930)

[email protected] (Arnaud Le Blanc via GitHub) Mon, 3 Aug 2026 14:34:30 +0000
Newsgroups php.cvs
Message-ID <[email protected]>
Author: Arnaud Le Blanc (arnaud-lb)
Committer: GitHub (web-flow)
Pusher: arnaud-lb
Date: 2026-08-03T16:34:27+02:00

Commit: https://github.com/php/php-src/commit/32402df2b56b94d18bd4882a95b8e75f7a897db6
Raw diff: https://github.com/php/php-src/commit/32402df2b56b94d18bd4882a95b8e75f7a897db6.diff

php_printf: introduce %pS to replace custom specifier %S (#22930)

The string formater supports custom format specifiers such as 'S' (zend_string*), but format strings using these specifiers do not pass the compiler's type checks that are performed on functions tagged with ZEND_ATTRIBUTE_FORMAT:

    Zend/zend_compile.c: In function 'zend_compile_closure_binding':
    Zend/zend_compile.c:8586:62: error: format '%S' expects argument of type 'wchar_t *', but argument 3 has type 'zend_string *' {aka 'struct _zend_string *'} [-Werror=format=]
     8586 |                         zend_error_noreturn(E_COMPILE_ERROR, "Cannot use variable $%S twice", var_name);
          |                                                              ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~  ~~~~~~~~
          |                                                                                               |
          |                                                                                               zend_string * {aka struct _zend_string *}

As a result we can not use these specifiers without resorting to workarounds:

 * Use variants of formatting functions that do not have ZEND_ATTRIBUTE_FORMAT [1]
 * Or declare the format string separately [2]

Here I re-introduce %S as %pS. The compiler will only see a %p specifier followed by the ordinary literal character S, so it will be happy about an argument of type zend_string*.

This trick can be applied to more custom specifiers.

[1] https://github.com/php/php-src/blob/0b5d9801ec3b53e84388239a5b9f85d005318b64/Zend/zend_compile.c#L8586-L8587
[2] https://github.com/php/php-src/blob/edc169e7705d5e4411865e9be92b50e82be78f4e/Zend/zend_partial.c#L680

Changed paths:
  M  UPGRADING.INTERNALS
  M  Zend/zend_compile.c
  M  ext/sqlite3/sqlite3.c
  M  main/spprintf.c


Diff:

diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS
index 8b4b5912317a..7bdbe08ce567 100644
--- a/UPGRADING.INTERNALS
+++ b/UPGRADING.INTERNALS
@@ -14,6 +14,19 @@ PHP 8.6 INTERNALS UPGRADE NOTES
 1. Internal API changes
 ========================
 
+- Breaking changes:
+  . String formatting functions now support the custom conversion specifiers
+    'pS' (zend_string*) and 'pp' (same as 'p'). Following the 'p' specifier with
+    an alpha-numeric character other than 'S' or 'p' is now an error.
+
+    Examples:
+
+    zend_string *str;
+    zend_spprintf("%pS", str);  // valid, same as "%S"
+    zend_spprintf("%pp", str);  // valid, same as "%p"
+    zend_spprintf("%pA", str);  // invalid
+    zend_spprintf("%ppA", str); // valid, same as zend_spprintf("%p%c", str, 'A')
+
 - Removed:
   . The misnamed ZVAL_IS_NULL() has been removed. Use Z_ISNULL() instead.
   . The zval_is_true() alias of zend_is_true() has been removed. Call
diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c
index 088573a50ee8..a2f126fb101d 100644
--- a/Zend/zend_compile.c
+++ b/Zend/zend_compile.c
@@ -8580,8 +8580,8 @@ static void zend_compile_closure_binding(znode *closure, zend_op_array *op_array
 
 		value = zend_hash_add(op_array->static_variables, var_name, &EG(uninitialized_zval));
 		if (!value) {
-			zend_error_noreturn_unchecked(E_COMPILE_ERROR,
-				"Cannot use variable $%S twice", var_name);
+			zend_error_noreturn(E_COMPILE_ERROR,
+				"Cannot use variable $%pS twice", var_name);
 		}
 
 		CG(zend_lineno) = zend_ast_get_lineno(var_name_ast);
diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c
index b81a0677a6c2..57271a629423 100644
--- a/ext/sqlite3/sqlite3.c
+++ b/ext/sqlite3/sqlite3.c
@@ -1660,7 +1660,7 @@ static int php_sqlite3_bind_params(php_sqlite3_stmt *stmt_obj) /* {{{ */
 					break;
 
 				default:
-					php_sqlite3_error(stmt_obj->db_obj, 0, "Unknown parameter type: %pd for parameter %pd", param->type, param->param_number);
+					php_sqlite3_error(stmt_obj->db_obj, 0, "Unknown parameter type: " ZEND_LONG_FMT " for parameter " ZEND_LONG_FMT, param->type, param->param_number);
 					return FAILURE;
 			}
 		} ZEND_HASH_FOREACH_END();
diff --git a/main/spprintf.c b/main/spprintf.c
index 6553853d8104..8d6b80258a72 100644
--- a/main/spprintf.c
+++ b/main/spprintf.c
@@ -362,6 +362,7 @@ static void xbuf_format_converter(void *xbuf, bool is_char, const char *fmt, va_
 					break;
 				}
 				case 'S': {
+format_zend_string:;
 					zend_string *str = va_arg(ap, zend_string*);
 					s_len = ZSTR_LEN(str);
 					s = ZSTR_VAL(str);
@@ -665,6 +666,24 @@ static void xbuf_format_converter(void *xbuf, bool is_char, const char *fmt, va_
 					 * we print "%p" to indicate that we don't handle "%p".
 					 */
 				case 'p':
+					/* %p[alnum]+ extensions */
+					switch (*(fmt+1)) {
+						case 'S':
+							/* zend_string* */
+							fmt++;
+							goto format_zend_string;
+						case 'p':
+							/* pointer */
+							fmt++;
+							break;
+						default:
+							if (isalnum(*(fmt+1))) {
+								zend_error_noreturn(E_CORE_ERROR,
+									"Invalid printf specifier \"p%c\"", *(fmt+1));
+							}
+							break;
+					}
+					/* Normal %p */
 					if (sizeof(char *) <= sizeof(uint64_t)) {
 						ui_num = (uint64_t)((size_t) va_arg(ap, char *));
 						s = ap_php_conv_p2(ui_num, 4, 'x',