Commit 0c1d809a75d for php.net

commit 0c1d809a75d05963511eaee8057f6147051dde30
Author: Weilin Du <weilindu@php.net>
Date:   Tue Jun 30 14:52:10 2026 +0800

    Add Locale::getDisplayKeyword() and Locale::getDisplayKeywordValue() (#22264)

    implement RFC:
    https://wiki.php.net/rfc/getdisplaykeyword_and_getdisplaykeywordvalue

diff --git a/NEWS b/NEWS
index 308ebe4d8f2..9d326431321 100644
--- a/NEWS
+++ b/NEWS
@@ -88,6 +88,9 @@ PHP                                                                        NEWS
 - Intl:
   . Fixed malformed ResourceBundle::get() error message when fallback is
     disabled. (Weilin Du)
+  . Added Locale::getDisplayKeyword() and Locale::getDisplayKeywordValue(),
+    with the alias of locale_get_display_keyword() and
+    locale_get_display_keyword_value() respectively. (Weilin Du)
   . Fix incorrect argument positions for invalid start/end arguments in
     transliterator_transliterate(). (Weilin Du)
   . Fixed IntlTimeZone::getDisplayName() to synchronize object error state
diff --git a/UPGRADING b/UPGRADING
index 1c75894512f..02aa821acec 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -208,6 +208,10 @@ PHP 8.6 UPGRADE NOTES
   . finfo_file() now works with remote streams.

 - Intl:
+  . Added Locale::getDisplayKeyword() and Locale::getDisplayKeywordValue(),
+    with the alias of locale_get_display_keyword() and
+    locale_get_display_keyword_value() respectively.
+    RFC: https://wiki.php.net/rfc/getdisplaykeyword_and_getdisplaykeywordvalue
   . Added IntlNumberRangeFormatter class to format an interval of two numbers
     with a given skeleton, locale, IntlNumberRangeFormatter::COLLAPSE_AUTO,
     IntlNumberRangeFormatter::COLLAPSE_NONE,
diff --git a/ext/intl/locale/locale.stub.php b/ext/intl/locale/locale.stub.php
index 03813ecaf19..fc012768de9 100644
--- a/ext/intl/locale/locale.stub.php
+++ b/ext/intl/locale/locale.stub.php
@@ -92,6 +92,16 @@ public static function getDisplayLanguage(string $locale, ?string $displayLocale
      */
     public static function getDisplayVariant(string $locale, ?string $displayLocale = null): string|false {}

+    /**
+     * @alias locale_get_display_keyword
+     */
+    public static function getDisplayKeyword(string $keyword, ?string $displayLocale = null): string|false {}
+
+    /**
+     * @alias locale_get_display_keyword_value
+     */
+    public static function getDisplayKeywordValue(string $locale, string $keyword, ?string $displayLocale = null): string|false {}
+
     /**
      * @tentative-return-type
      * @alias locale_compose
diff --git a/ext/intl/locale/locale_arginfo.h b/ext/intl/locale/locale_arginfo.h
index 1d6e6683e21..a01f583b045 100644
Binary files a/ext/intl/locale/locale_arginfo.h and b/ext/intl/locale/locale_arginfo.h differ
diff --git a/ext/intl/locale/locale_methods.cpp b/ext/intl/locale/locale_methods.cpp
index 14fa2a8f6bf..0733ffc8abb 100644
--- a/ext/intl/locale/locale_methods.cpp
+++ b/ext/intl/locale/locale_methods.cpp
@@ -44,6 +44,8 @@ ZEND_EXTERN_MODULE_GLOBALS( intl )
 #define EXTLANG_PREFIX "a"
 #define PRIVATE_PREFIX "x"
 #define DISP_NAME "name"
+#define DISP_KEYWORD "keyword"
+#define DISP_KEYWORD_VALUE "keyword_value"

 #define MAX_NO_VARIANT  15
 #define MAX_NO_EXTLANG  3
@@ -671,6 +673,107 @@ static void get_icu_disp_value_src_php( const char* tag_name, INTERNAL_FUNCTION_
 }
 /* }}} */

+/* {{{
+ * common code shared by display keyword functions to get the value from ICU
+ }}} */
+static void get_icu_disp_keyword_value_src_php(const char* tag_name, INTERNAL_FUNCTION_PARAMETERS)
+{
+	char*       loc_name            = NULL;
+	size_t      loc_name_len        = 0;
+	char*       keyword_name        = NULL;
+	size_t      keyword_name_len    = 0;
+	char*       disp_loc_name       = NULL;
+	size_t      disp_loc_name_len   = 0;
+	int         free_loc_name       = 0;
+
+	UChar*      disp_name           = NULL;
+	int32_t     disp_name_len       = 0;
+	int32_t     buflen              = 512;
+	UErrorCode  status              = U_ZERO_ERROR;
+
+	zend_string* u8str;
+	char*       msg                 = NULL;
+
+	intl_error_reset( NULL );
+
+	if (strcmp(tag_name, DISP_KEYWORD) == 0) {
+		ZEND_PARSE_PARAMETERS_START(1, 2)
+			Z_PARAM_PATH(keyword_name, keyword_name_len)
+			Z_PARAM_OPTIONAL
+			Z_PARAM_PATH_OR_NULL(disp_loc_name, disp_loc_name_len)
+		ZEND_PARSE_PARAMETERS_END();
+	} else {
+		ZEND_PARSE_PARAMETERS_START(2, 3)
+			Z_PARAM_PATH(loc_name, loc_name_len)
+			Z_PARAM_PATH(keyword_name, keyword_name_len)
+			Z_PARAM_OPTIONAL
+			Z_PARAM_PATH_OR_NULL(disp_loc_name, disp_loc_name_len)
+		ZEND_PARSE_PARAMETERS_END();
+
+		if (loc_name_len > ULOC_FULLNAME_CAPACITY) {
+			intl_error_set( NULL, U_ILLEGAL_ARGUMENT_ERROR, "name too long");
+			RETURN_FALSE;
+		}
+
+		if (loc_name_len == 0) {
+			loc_name = (char *)intl_locale_get_default();
+		}
+	}
+
+	if (!disp_loc_name) {
+		disp_loc_name = estrdup(intl_locale_get_default());
+		free_loc_name = 1;
+	}
+
+	do {
+		disp_name = reinterpret_cast<UChar *>(erealloc(disp_name, buflen * sizeof(UChar)));
+		disp_name_len = buflen;
+
+		if (strcmp(tag_name, DISP_KEYWORD) == 0) {
+			buflen = uloc_getDisplayKeyword(keyword_name, disp_loc_name, disp_name, disp_name_len, &status);
+		} else {
+			buflen = uloc_getDisplayKeywordValue(loc_name, keyword_name, disp_loc_name, disp_name, disp_name_len, &status);
+		}
+
+		/* U_STRING_NOT_TERMINATED_WARNING is admissible here; don't look for it */
+		if (U_FAILURE(status)) {
+			if (status == U_BUFFER_OVERFLOW_ERROR) {
+				status = U_ZERO_ERROR;
+				continue;
+			}
+
+			spprintf(&msg, 0, "unable to get locale %s", tag_name);
+			intl_error_set( NULL, status, msg);
+			efree(msg);
+			if (disp_name) {
+				efree(disp_name);
+			}
+			if (free_loc_name) {
+				efree((void *)disp_loc_name);
+				disp_loc_name = NULL;
+			}
+			RETURN_FALSE;
+		}
+	} while (buflen > disp_name_len);
+
+	if (free_loc_name) {
+		efree((void *)disp_loc_name);
+		disp_loc_name = NULL;
+	}
+
+	u8str = intl_convert_utf16_to_utf8(disp_name, buflen, &status);
+	efree(disp_name);
+	if (!u8str) {
+		spprintf(&msg, 0, "error converting display name for %s to UTF-8", tag_name);
+		intl_error_set( NULL, status, msg);
+		efree(msg);
+		RETURN_FALSE;
+	}
+
+	RETVAL_NEW_STR(u8str);
+}
+/* }}} */
+
 /* {{{ gets the name for the $locale in $in_locale or default_locale */
 U_CFUNC PHP_FUNCTION(locale_get_display_name)
 {
@@ -711,6 +814,20 @@ U_CFUNC PHP_FUNCTION(locale_get_display_variant)
 {
 	get_icu_disp_value_src_php( LOC_VARIANT_TAG , INTERNAL_FUNCTION_PARAM_PASSTHRU );
 }
+/* }}} */
+
+/* {{{ gets the keyword display label in $in_locale or default_locale */
+U_CFUNC PHP_FUNCTION(locale_get_display_keyword)
+{
+	get_icu_disp_keyword_value_src_php(DISP_KEYWORD, INTERNAL_FUNCTION_PARAM_PASSTHRU);
+}
+/* }}} */
+
+/* {{{ gets the keyword value display label in $in_locale or default_locale */
+U_CFUNC PHP_FUNCTION(locale_get_display_keyword_value)
+{
+	get_icu_disp_keyword_value_src_php(DISP_KEYWORD_VALUE, INTERNAL_FUNCTION_PARAM_PASSTHRU);
+}
 /* }}} */

  /* {{{ return an associative array containing keyword-value
diff --git a/ext/intl/php_intl.stub.php b/ext/intl/php_intl.stub.php
index 4bcb8587f78..1e6c5cacf20 100644
--- a/ext/intl/php_intl.stub.php
+++ b/ext/intl/php_intl.stub.php
@@ -487,6 +487,10 @@ function locale_get_display_language(string $locale, ?string $displayLocale = nu

 function locale_get_display_variant(string $locale, ?string $displayLocale = null): string|false {}

+function locale_get_display_keyword(string $keyword, ?string $displayLocale = null): string|false {}
+
+function locale_get_display_keyword_value(string $locale, string $keyword, ?string $displayLocale = null): string|false {}
+
 function locale_compose(array $subtags): string|false {}

 function locale_parse(string $locale): ?array {}
diff --git a/ext/intl/php_intl_arginfo.h b/ext/intl/php_intl_arginfo.h
index 81160349980..00de5986f1e 100644
Binary files a/ext/intl/php_intl_arginfo.h and b/ext/intl/php_intl_arginfo.h differ
diff --git a/ext/intl/tests/locale/bug74993.phpt b/ext/intl/tests/locale/bug74993.phpt
index 1d0a1a97b75..7bcf931c4e2 100644
--- a/ext/intl/tests/locale/bug74993.phpt
+++ b/ext/intl/tests/locale/bug74993.phpt
@@ -5,6 +5,8 @@
 --FILE--
 <?php
 $funcs = [
+    'locale_get_display_keyword',
+    'locale_get_display_keyword_value',
     'locale_get_display_language',
     'locale_get_display_name',
     'locale_get_display_region',
@@ -19,6 +21,23 @@
 }
 ?>
 --EXPECT--
+Function [ <internal:intl> function locale_get_display_keyword ] {
+
+  - Parameters [2] {
+    Parameter #0 [ <required> string $keyword ]
+    Parameter #1 [ <optional> ?string $displayLocale = null ]
+  }
+  - Return [ string|false ]
+}
+Function [ <internal:intl> function locale_get_display_keyword_value ] {
+
+  - Parameters [3] {
+    Parameter #0 [ <required> string $locale ]
+    Parameter #1 [ <required> string $keyword ]
+    Parameter #2 [ <optional> ?string $displayLocale = null ]
+  }
+  - Return [ string|false ]
+}
 Function [ <internal:intl> function locale_get_display_language ] {

   - Parameters [2] {
diff --git a/ext/intl/tests/locale_get_display_keyword.phpt b/ext/intl/tests/locale_get_display_keyword.phpt
new file mode 100644
index 00000000000..9d53d7a0258
--- /dev/null
+++ b/ext/intl/tests/locale_get_display_keyword.phpt
@@ -0,0 +1,40 @@
+--TEST--
+locale_get_display_keyword() basic
+--EXTENSIONS--
+intl
+--FILE--
+<?php
+
+function ut_main()
+{
+    $default = ut_loc_get_default();
+    ut_loc_set_default('en');
+
+    $keyword = ut_loc_get_display_keyword('calendar', 'en');
+    var_dump($keyword);
+    var_dump(ut_loc_get_display_keyword('calendar', null) === $keyword);
+
+    $keywordValue = ut_loc_get_display_keyword_value('de_DE@calendar=gregorian', 'calendar', 'en');
+    var_dump($keywordValue);
+    var_dump(ut_loc_get_display_keyword_value('de_DE@calendar=gregorian', 'calendar', null) === $keywordValue);
+
+    $collationValue = ut_loc_get_display_keyword_value('de_DE@collation=phonebook', 'collation', 'en');
+    var_dump($collationValue);
+
+    ut_loc_set_default($default);
+}
+
+include_once 'ut_common.inc';
+ut_run();
+?>
+--EXPECTREGEX--
+string\([1-9][0-9]*\) "[A-Za-z ]*Calendar[A-Za-z ]*"
+bool\(true\)
+string\([1-9][0-9]*\) "[A-Za-z ]*Gregorian[A-Za-z ]*"
+bool\(true\)
+string\([1-9][0-9]*\) "[A-Za-z ]*Phonebook[A-Za-z ]*"
+string\([1-9][0-9]*\) "[A-Za-z ]*Calendar[A-Za-z ]*"
+bool\(true\)
+string\([1-9][0-9]*\) "[A-Za-z ]*Gregorian[A-Za-z ]*"
+bool\(true\)
+string\([1-9][0-9]*\) "[A-Za-z ]*Phonebook[A-Za-z ]*"
diff --git a/ext/intl/tests/locale_get_display_keyword_error.phpt b/ext/intl/tests/locale_get_display_keyword_error.phpt
new file mode 100644
index 00000000000..6f489ccfe8f
--- /dev/null
+++ b/ext/intl/tests/locale_get_display_keyword_error.phpt
@@ -0,0 +1,20 @@
+--TEST--
+locale_get_display_keyword_value() error path
+--EXTENSIONS--
+intl
+--FILE--
+<?php
+
+$locale = str_repeat('*', 256);
+
+var_dump(Locale::getDisplayKeywordValue($locale, 'calendar', 'en'));
+var_dump(intl_get_error_message());
+var_dump(locale_get_display_keyword_value($locale, 'calendar', 'en'));
+var_dump(intl_get_error_message());
+
+?>
+--EXPECT--
+bool(false)
+string(73) "Locale::getDisplayKeywordValue(): name too long: U_ILLEGAL_ARGUMENT_ERROR"
+bool(false)
+string(75) "locale_get_display_keyword_value(): name too long: U_ILLEGAL_ARGUMENT_ERROR"
diff --git a/ext/intl/tests/locale_get_display_keyword_null_bytes.phpt b/ext/intl/tests/locale_get_display_keyword_null_bytes.phpt
new file mode 100644
index 00000000000..fe83ac1b731
--- /dev/null
+++ b/ext/intl/tests/locale_get_display_keyword_null_bytes.phpt
@@ -0,0 +1,40 @@
+--TEST--
+locale_get_display_keyword() throwing null bytes exceptions.
+--EXTENSIONS--
+intl
+--FILE--
+<?php
+
+function ut_main()
+{
+    $calls = [
+        fn() => ut_loc_get_display_keyword("cur\0rency", "fr"),
+        fn() => ut_loc_get_display_keyword("currency", "f\0r"),
+        fn() => ut_loc_get_display_keyword_value("de_DE@calendar=gregorian\0", "calendar", "en"),
+        fn() => ut_loc_get_display_keyword_value("de_DE@calendar=gregorian", "cal\0endar", "en"),
+        fn() => ut_loc_get_display_keyword_value("de_DE@calendar=gregorian", "calendar", "e\0n"),
+    ];
+
+    foreach ($calls as $call) {
+        try {
+            $call();
+        } catch (\ValueError $e) {
+            echo $e->getMessage(), PHP_EOL;
+        }
+    }
+}
+
+include_once 'ut_common.inc';
+ut_run();
+?>
+--EXPECT--
+Locale::getDisplayKeyword(): Argument #1 ($keyword) must not contain any null bytes
+Locale::getDisplayKeyword(): Argument #2 ($displayLocale) must not contain any null bytes
+Locale::getDisplayKeywordValue(): Argument #1 ($locale) must not contain any null bytes
+Locale::getDisplayKeywordValue(): Argument #2 ($keyword) must not contain any null bytes
+Locale::getDisplayKeywordValue(): Argument #3 ($displayLocale) must not contain any null bytes
+locale_get_display_keyword(): Argument #1 ($keyword) must not contain any null bytes
+locale_get_display_keyword(): Argument #2 ($displayLocale) must not contain any null bytes
+locale_get_display_keyword_value(): Argument #1 ($locale) must not contain any null bytes
+locale_get_display_keyword_value(): Argument #2 ($keyword) must not contain any null bytes
+locale_get_display_keyword_value(): Argument #3 ($displayLocale) must not contain any null bytes
diff --git a/ext/intl/tests/ut_common.inc b/ext/intl/tests/ut_common.inc
index fdc013dea41..26f33f75f7f 100644
--- a/ext/intl/tests/ut_common.inc
+++ b/ext/intl/tests/ut_common.inc
@@ -272,6 +272,14 @@ function ut_loc_get_display_variant( $locale , $dispLocale )
 {
     return $GLOBALS['oo-mode'] ? Locale::getDisplayVariant( $locale , $dispLocale ) : locale_get_display_variant( $locale, $dispLocale  );
 }
+function ut_loc_get_display_keyword( $keyword , $dispLocale )
+{
+    return $GLOBALS['oo-mode'] ? Locale::getDisplayKeyword( $keyword , $dispLocale ) : locale_get_display_keyword( $keyword, $dispLocale );
+}
+function ut_loc_get_display_keyword_value( $locale , $keyword , $dispLocale )
+{
+    return $GLOBALS['oo-mode'] ? Locale::getDisplayKeywordValue( $locale , $keyword , $dispLocale ) : locale_get_display_keyword_value( $locale, $keyword, $dispLocale );
+}
 function ut_loc_locale_compose( $loc_parts_arr )
 {
     return $GLOBALS['oo-mode'] ? Locale::composeLocale( $loc_parts_arr ) : locale_compose( $loc_parts_arr );