Skip to content

[Grapheme] Fix grapheme_strpos() and friends matching inside a grapheme cluster - #719

Merged
nicolas-grekas merged 1 commit into
1.xfrom
grapheme-position-cluster-boundaries
Oct 15, 2026
Merged

nicolas-grekas merged 1 commit into
1.xfrom
grapheme-position-cluster-boundaries

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Member
Q A
Branch? 1.x
Bug fix? yes
New feature? no
Deprecations? no
Issues -
License MIT

Found while fixing symfony/symfony#66969 without ext-intl.

The polyfills of grapheme_str[ri]?pos() use plain strpos(), so they find "q" inside "q̃" where the extension returns false:

call ext-intl polyfill
grapheme_strpos(" q\u{0303}", 'q') false 1
grapheme_strpos("q\u{0303}", "\u{0303}") false 1
grapheme_strpos("\u{1F44D}\u{1F3FB}", "\u{1F44D}") false 0

A match is now rejected when it splits a grapheme cluster. The extension still accepts a match that leaves out only characters it ignores, like U+200C in Persian text, so the polyfill does the same. The list of these characters comes from ICU 74.

On 100k random strings, the polyfill now disagrees with the extension half as often on whether there's a match. Most of the remaining differences come from the ZWJ simplification of the cluster regexp used when PCRE < 10.44. That simplification also makes about 500 of these cases disagree where they agreed before.

When the match starts and ends next to ASCII characters, the cost is unchanged. Otherwise the lookup is about 2x slower, with no extra memory.

…me cluster

The intl extension does not find "q" in "q̃" because the match would split a grapheme cluster. A match can still end or start inside a cluster when what it leaves out is only characters like U+200C that the extension ignores.
nicolas-grekas added a commit to symfony/symfony that referenced this pull request Oct 15, 2026
… ext-intl (nicolas-grekas)

This PR was merged into the 6.4 branch.

Discussion
----------

[String] Fix UnicodeString::replace() and split() without ext-intl

| Q             | A
| ------------- | ---
| Branch?       | 6.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Issues        | -
| License       | MIT

Follow-up of #66969, which broke the [x86 / minimal-exts job](https://github.com/symfony/symfony/actions/runs/38707200364/job/116329436616), where ext-intl is not loaded.

Without ext-intl, `grapheme_strpos()` reports a needle that starts inside a grapheme at the next grapheme. So `u("می\u{200C}خواهم")->replace("\u{200C}", ' ')` didn't find the ZWNJ. This PR also searches the needle from the previous grapheme in that case.

The polyfill also finds "q" inside "q̃", so this test case now runs only with ext-intl. The polyfill is fixed in symfony/polyfill#719.

Commits
-------

81c0538 [String] Fix UnicodeString::replace() and split() without ext-intl
@nicolas-grekas
nicolas-grekas merged commit 6eb2598 into 1.x Oct 15, 2026
40 checks passed
@nicolas-grekas
nicolas-grekas deleted the grapheme-position-cluster-boundaries branch October 15, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant