Commit 9905878a4ad for php.net

commit 9905878a4ad71b70ef467bf3b590ba77e335c549
Author: Weilin Du <weilindu@php.net>
Date:   Wed Aug 5 03:27:03 2026 +0800

    ext/intl: Add SpoofChecker::getBidiSkeleton() (#23017)

    This implement the SpoofChecker::getBidiSkeleton() function.

diff --git a/NEWS b/NEWS
index 460051eed71..b8bd4280baa 100644
--- a/NEWS
+++ b/NEWS
@@ -21,6 +21,7 @@ PHP                                                                        NEWS
   . Implemented GH-20255 (Add a predefined calendar constant in
     IntlDateFormatter for the proleptic gregorian calendar). (David Carlier)
   . Added SpoofChecker::areBidiConfusable(). (David Carlier)
+  . Added SpoofChecker::getBidiSkeleton(). (Weilin Du)

 - Reflection:
   . Added ReflectionAttribute::inNamespace(),
diff --git a/UPGRADING b/UPGRADING
index b988847c8f5..9bbe6d42c80 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -332,6 +332,8 @@ PHP 8.6 UPGRADE NOTES
     confusable for a given text direction, along with the SpoofChecker::LTR
     and SpoofChecker::RTL direction constants.
     It is supported from icu 74.
+  . Added SpoofChecker::getBidiSkeleton() to generate a confusable skeleton for
+    a given text direction. It is supported from icu 74.

 - IO:
   . Added new polling API.
@@ -488,6 +490,7 @@ PHP 8.6 UPGRADE NOTES
   . Locale::getDisplayKeyword() and Locale::getDisplayKeywordValue()
     RFC: https://wiki.php.net/rfc/getdisplaykeyword_and_getdisplaykeywordvalue
   . SpoofChecker::areBidiConfusable()
+  . SpoofChecker::getBidiSkeleton()

 - mysqli:
   . Added mysqli::quote_string() and mysqli_quote_string().
diff --git a/ext/intl/spoofchecker/spoofchecker.stub.php b/ext/intl/spoofchecker/spoofchecker.stub.php
index f3de2fcfcf1..1ffc61faaaf 100644
--- a/ext/intl/spoofchecker/spoofchecker.stub.php
+++ b/ext/intl/spoofchecker/spoofchecker.stub.php
@@ -81,6 +81,8 @@ public function setRestrictionLevel(int $level): void {}
     public function setAllowedChars(string $pattern, int $patternOptions = 0): void {}

 #if U_ICU_VERSION_MAJOR_NUM >= 74
+    public function getBidiSkeleton(int $direction, string $string): string|false {}
+
     /**
      * @param int $errorCode
      */
diff --git a/ext/intl/spoofchecker/spoofchecker_arginfo.h b/ext/intl/spoofchecker/spoofchecker_arginfo.h
index cc4274d8bfe..380f2721355 100644
Binary files a/ext/intl/spoofchecker/spoofchecker_arginfo.h and b/ext/intl/spoofchecker/spoofchecker_arginfo.h differ
diff --git a/ext/intl/spoofchecker/spoofchecker_main.cpp b/ext/intl/spoofchecker/spoofchecker_main.cpp
index d80ada93660..bb1dfe6bfc7 100644
--- a/ext/intl/spoofchecker/spoofchecker_main.cpp
+++ b/ext/intl/spoofchecker/spoofchecker_main.cpp
@@ -225,6 +225,70 @@ U_CFUNC PHP_METHOD(Spoofchecker, setAllowedChars)
 }

 #if U_ICU_VERSION_MAJOR_NUM >= 74
+/* {{{ Get the confusable skeleton for an identifier in a given text direction */
+U_CFUNC PHP_METHOD(Spoofchecker, getBidiSkeleton)
+{
+	zend_long direction;
+	zend_string *string;
+	SPOOFCHECKER_METHOD_INIT_VARS;
+
+	ZEND_PARSE_PARAMETERS_START(2, 2)
+		Z_PARAM_LONG(direction)
+		Z_PARAM_STR(string)
+	ZEND_PARSE_PARAMETERS_END();
+
+	SPOOFCHECKER_METHOD_FETCH_OBJECT;
+
+	if (direction != UBIDI_LTR && direction != UBIDI_RTL) {
+		zend_argument_value_error(1, "must be either Spoofchecker::LTR or Spoofchecker::RTL");
+		RETURN_THROWS();
+	}
+
+	if (UNEXPECTED(ZSTR_LEN(string) > INT32_MAX)) {
+		SPOOFCHECKER_ERROR_CODE(co) = U_BUFFER_OVERFLOW_ERROR;
+		intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
+			"Failed to convert input string to UTF-16");
+		RETURN_FALSE;
+	}
+
+	int32_t utf16_len;
+	u_strFromUTF8(nullptr, 0, &utf16_len, ZSTR_VAL(string), (int32_t) ZSTR_LEN(string),
+		SPOOFCHECKER_ERROR_CODE_P(co));
+	if (U_FAILURE(SPOOFCHECKER_ERROR_CODE(co)) && SPOOFCHECKER_ERROR_CODE(co) != U_BUFFER_OVERFLOW_ERROR) {
+		intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
+			"Failed to convert input string to UTF-16");
+		RETURN_FALSE;
+	}
+	SPOOFCHECKER_ERROR_CODE(co) = U_ZERO_ERROR;
+
+	int32_t result_len = uspoof_getBidiSkeletonUTF8(
+		co->uspoof, (UBiDiDirection) direction, ZSTR_VAL(string), (int32_t) ZSTR_LEN(string),
+		nullptr, 0, SPOOFCHECKER_ERROR_CODE_P(co));
+	if (U_FAILURE(SPOOFCHECKER_ERROR_CODE(co)) && SPOOFCHECKER_ERROR_CODE(co) != U_BUFFER_OVERFLOW_ERROR) {
+		intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
+			"Failed to generate skeleton");
+		RETURN_FALSE;
+	}
+
+	zend_string *result = zend_string_alloc(result_len, false);
+	int32_t result_capacity = result_len < INT32_MAX ? result_len + 1 : result_len;
+	SPOOFCHECKER_ERROR_CODE(co) = U_ZERO_ERROR;
+	result_len = uspoof_getBidiSkeletonUTF8(
+		co->uspoof, (UBiDiDirection) direction, ZSTR_VAL(string), (int32_t) ZSTR_LEN(string),
+		ZSTR_VAL(result), result_capacity, SPOOFCHECKER_ERROR_CODE_P(co));
+	if (U_FAILURE(SPOOFCHECKER_ERROR_CODE(co))) {
+		zend_string_release(result);
+		intl_errors_set(SPOOFCHECKER_ERROR_P(co), SPOOFCHECKER_ERROR_CODE(co),
+			"Failed to generate skeleton");
+		RETURN_FALSE;
+	}
+	SPOOFCHECKER_ERROR_CODE(co) = U_ZERO_ERROR;
+	ZSTR_LEN(result) = result_len;
+	ZSTR_VAL(result)[result_len] = '\0';
+	RETURN_STR(result);
+}
+/* }}} */
+
 /* {{{ Checks if a given text contains any confusable characters, for a given text direction */
 U_CFUNC PHP_METHOD(Spoofchecker, areBidiConfusable)
 {
diff --git a/ext/intl/tests/spoofchecker_bidi_skeleton.phpt b/ext/intl/tests/spoofchecker_bidi_skeleton.phpt
new file mode 100644
index 00000000000..c9b8a1fca1b
--- /dev/null
+++ b/ext/intl/tests/spoofchecker_bidi_skeleton.phpt
@@ -0,0 +1,50 @@
+--TEST--
+Spoofchecker::getBidiSkeleton()
+--EXTENSIONS--
+intl
+--SKIPIF--
+<?php if (version_compare(INTL_ICU_VERSION, '74.0') < 0) die('skip for ICU >= 74.0'); ?>
+--FILE--
+<?php
+$checker = new Spoofchecker();
+
+var_dump($checker->getBidiSkeleton(Spoofchecker::LTR, ""));
+
+try {
+    $checker->getBidiSkeleton(Spoofchecker::RTL + 1, "a");
+} catch (ValueError $e) {
+    echo $e::class, ': ', $e->getMessage(), PHP_EOL;
+}
+
+/* These identifiers are confusable in a left-to-right context only. */
+var_dump(
+    $checker->getBidiSkeleton(Spoofchecker::LTR, "A1\u{05D0}") ===
+    $checker->getBidiSkeleton(Spoofchecker::LTR, "A\u{05D0}1")
+);
+var_dump(
+    $checker->getBidiSkeleton(Spoofchecker::RTL, "A1\u{05D0}") ===
+    $checker->getBidiSkeleton(Spoofchecker::RTL, "A\u{05D0}1")
+);
+
+/* These identifiers are confusable in a right-to-left context only. */
+var_dump(
+    $checker->getBidiSkeleton(Spoofchecker::LTR, "\u{05D0}A_1") ===
+    $checker->getBidiSkeleton(Spoofchecker::LTR, "\u{05D0}1_A")
+);
+var_dump(
+    $checker->getBidiSkeleton(Spoofchecker::RTL, "\u{05D0}A_1") ===
+    $checker->getBidiSkeleton(Spoofchecker::RTL, "\u{05D0}1_A")
+);
+
+var_dump($checker->getBidiSkeleton(Spoofchecker::LTR, "\x80"));
+var_dump(intl_get_error_code() === U_INVALID_CHAR_FOUND);
+?>
+--EXPECT--
+string(0) ""
+ValueError: Spoofchecker::getBidiSkeleton(): Argument #1 ($direction) must be either Spoofchecker::LTR or Spoofchecker::RTL
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+bool(false)
+bool(true)