Skip to content

Commit d76e08c

Browse files
committed
fix: handle PREG_REPLACE_COUNT_CHANGES when replacements cancel out
Track local replacement counts separately and only report actual changes if the final output differs from the input. This ensures PREG_REPLACE_COUNT_CHANGES correctly returns 0 when individual replacements occur but the final result is identical to the original string.
1 parent 14ca060 commit d76e08c

File tree

2 files changed

+52
-7
lines changed

2 files changed

+52
-7
lines changed

ext/pcre/php_pcre.c

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,13 @@ PHPAPI zend_string *php_pcre_replace_impl(pcre_cache_entry *pce, zend_string *su
16411641
}
16421642
}
16431643

1644+
const bool count_changes = (flags & PREG_REPLACE_COUNT_CHANGES) != 0;
1645+
size_t local_replace_count = 0;
1646+
size_t *replace_count_ptr = replace_count;
1647+
if (count_changes && replace_count) {
1648+
replace_count_ptr = &local_replace_count;
1649+
}
1650+
16441651
options = (pce->compile_options & PCRE2_UTF) ? 0 : PCRE2_NO_UTF_CHECK;
16451652

16461653
/* Array of subpattern offsets */
@@ -1769,14 +1776,14 @@ PHPAPI zend_string *php_pcre_replace_impl(pcre_cache_entry *pce, zend_string *su
17691776
result_len += (walkbuf - (ZSTR_VAL(result) + result_len));
17701777
}
17711778

1772-
if (replace_count) {
1779+
if (replace_count_ptr) {
17731780
bool count_changes = flags & PREG_REPLACE_COUNT_CHANGES;
17741781
if (!count_changes) {
1775-
++*replace_count;
1782+
++*replace_count_ptr;
17761783
} else {
17771784
if (rep_len != match_len_local ||
17781785
(match_len_local && memcmp(rep_ptr, match, match_len_local) != 0)) {
1779-
++*replace_count;
1786+
++*replace_count_ptr;
17801787
}
17811788
}
17821789
}
@@ -1855,6 +1862,15 @@ PHPAPI zend_string *php_pcre_replace_impl(pcre_cache_entry *pce, zend_string *su
18551862
pcre2_match_data_free(match_data);
18561863
}
18571864

1865+
if (count_changes && replace_count && result != NULL) {
1866+
/* If the final output is identical to the input, no effective changes happened. */
1867+
if (ZSTR_LEN(result) == subject_len
1868+
&& (subject_len == 0 || memcmp(ZSTR_VAL(result), subject, subject_len) == 0)) {
1869+
local_replace_count = 0;
1870+
}
1871+
*replace_count += local_replace_count;
1872+
}
1873+
18581874
return result;
18591875
}
18601876
/* }}} */
@@ -1913,6 +1929,13 @@ static zend_string *php_pcre_replace_func_impl(pcre_cache_entry *pce, zend_strin
19131929

19141930
options = (pce->compile_options & PCRE2_UTF) ? 0 : PCRE2_NO_UTF_CHECK;
19151931

1932+
const bool count_changes = (flags & PREG_REPLACE_COUNT_CHANGES) != 0;
1933+
size_t local_replace_count = 0;
1934+
size_t *replace_count_ptr = replace_count;
1935+
if (count_changes && replace_count) {
1936+
replace_count_ptr = &local_replace_count;
1937+
}
1938+
19161939
/* Array of subpattern offsets */
19171940
PCRE2_SIZE *const offsets = pcre2_get_ovector_pointer(match_data);
19181941

@@ -1961,15 +1984,14 @@ static zend_string *php_pcre_replace_func_impl(pcre_cache_entry *pce, zend_strin
19611984
goto error;
19621985
}
19631986

1964-
if (replace_count) {
1965-
zend_long count_changes = flags & PREG_REPLACE_COUNT_CHANGES;
1987+
if (replace_count_ptr) {
19661988
if (!count_changes) {
1967-
++*replace_count;
1989+
++*replace_count_ptr;
19681990
} else {
19691991
size_t match_len = (size_t)(offsets[1] - offsets[0]);
19701992
if (ZSTR_LEN(eval_result) != match_len ||
19711993
(match_len && memcmp(ZSTR_VAL(eval_result), match, match_len) != 0)) {
1972-
++*replace_count;
1994+
++*replace_count_ptr;
19731995
}
19741996
}
19751997
}
@@ -2066,6 +2088,16 @@ static zend_string *php_pcre_replace_func_impl(pcre_cache_entry *pce, zend_strin
20662088
}
20672089
mdata_used = old_mdata_used;
20682090

2091+
if (count_changes && replace_count && result != NULL) {
2092+
/* If the final output is identical to the input, no effective changes happened. */
2093+
if (ZSTR_LEN(result) == ZSTR_LEN(subject_str)
2094+
&& (ZSTR_LEN(subject_str) == 0
2095+
|| memcmp(ZSTR_VAL(result), ZSTR_VAL(subject_str), ZSTR_LEN(subject_str)) == 0)) {
2096+
local_replace_count = 0;
2097+
}
2098+
*replace_count += local_replace_count;
2099+
}
2100+
20692101
return result;
20702102
}
20712103

ext/pcre/tests/preg_replace_count_changes.phpt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ $after = preg_replace_callback('/a/', fn($m) => 'x', $before, -1, $count, PREG_R
4242
show("callback_change", $before, $after, $count);
4343
echo "callback_change: " . $after . "\n";
4444

45+
/* Edge case: replacements change locally but cancel out globally */
46+
function cancel_out_callback($arr) {
47+
return match ($arr[0]) {
48+
'a' => 'ab',
49+
'bba' => 'ba',
50+
};
51+
}
52+
$before3 = "abba";
53+
$after = preg_replace_callback('/^a|bba/', 'cancel_out_callback', $before3, -1, $count, PREG_REPLACE_COUNT_CHANGES);
54+
show("callback_cancel_out", $before3, $after, $count);
55+
4556
/* Empty string match behavior */
4657
$before2 = "abc";
4758
$after = preg_replace('/^/', '', $before2, -1, $count, PREG_REPLACE_COUNT_CHANGES);
@@ -67,6 +78,8 @@ callback_identity: 0 REPLACEMENTS
6778
callback_change: CHANGED
6879
callback_change: 2 REPLACEMENTS
6980
callback_change: xbcx
81+
callback_cancel_out: NO REPLACEMENTS
82+
callback_cancel_out: 0 REPLACEMENTS
7083
empty_match_identity: NO REPLACEMENTS
7184
empty_match_identity: 0 REPLACEMENTS
7285
empty_match_change: CHANGED

0 commit comments

Comments
 (0)