diff --git a/.github/actions/configure-alpine/action.yml b/.github/actions/configure-alpine/action.yml
index d1b4cfea0412..2562bbf57557 100644
--- a/.github/actions/configure-alpine/action.yml
+++ b/.github/actions/configure-alpine/action.yml
@@ -51,6 +51,7 @@ runs:
--with-openssl \
--with-gmp \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
${{ inputs.skipSlow == 'false' && '--with-enchant=/usr' || '' }} \
diff --git a/.github/actions/configure-gentoo/action.yml b/.github/actions/configure-gentoo/action.yml
index e1ae914681fb..1fd8d612ed89 100644
--- a/.github/actions/configure-gentoo/action.yml
+++ b/.github/actions/configure-gentoo/action.yml
@@ -52,6 +52,7 @@ runs:
--with-openssl \
--with-gmp \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
${{ inputs.skipSlow == 'false' && '--with-enchant=/usr' || '' }} \
diff --git a/.github/actions/configure-macos/action.yml b/.github/actions/configure-macos/action.yml
index b9739a790530..ea99c4a0b55c 100644
--- a/.github/actions/configure-macos/action.yml
+++ b/.github/actions/configure-macos/action.yml
@@ -56,6 +56,7 @@ runs:
--with-gmp="$BREW_OPT"/gmp \
--with-iconv="$BREW_OPT"/libiconv \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
--enable-sysvmsg \
diff --git a/.github/actions/configure-x32/action.yml b/.github/actions/configure-x32/action.yml
index a5c5df4f7971..115f46d30c73 100644
--- a/.github/actions/configure-x32/action.yml
+++ b/.github/actions/configure-x32/action.yml
@@ -52,6 +52,7 @@ runs:
--with-openssl \
--with-gmp \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
--enable-sysvmsg \
diff --git a/.github/actions/configure-x64/action.yml b/.github/actions/configure-x64/action.yml
index ff746791fb95..898dc6e2cdfc 100644
--- a/.github/actions/configure-x64/action.yml
+++ b/.github/actions/configure-x64/action.yml
@@ -54,6 +54,7 @@ runs:
--with-openssl \
--with-gmp \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
${{ inputs.skipSlow == 'false' && '--with-enchant=/usr' || '' }} \
diff --git a/.github/actions/freebsd/action.yml b/.github/actions/freebsd/action.yml
index 423107a0f896..694781e9e6ff 100644
--- a/.github/actions/freebsd/action.yml
+++ b/.github/actions/freebsd/action.yml
@@ -80,6 +80,7 @@ runs:
--with-openssl \
--with-iconv=/usr/local \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
--with-ffi \
diff --git a/.github/actions/solaris/action.yml b/.github/actions/solaris/action.yml
index ef7cffe32abc..c3a4e2e34616 100644
--- a/.github/actions/solaris/action.yml
+++ b/.github/actions/solaris/action.yml
@@ -51,6 +51,7 @@ runs:
--enable-sockets \
--with-openssl \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
--enable-zend-test \
diff --git a/.github/scripts/windows/build_task.bat b/.github/scripts/windows/build_task.bat
index 1177cef3be4d..7e92ce2741eb 100644
--- a/.github/scripts/windows/build_task.bat
+++ b/.github/scripts/windows/build_task.bat
@@ -41,6 +41,7 @@ if "%CLANG_TOOLSET%" equ "1" (
cmd /c configure.bat ^
--enable-snapshot-build ^
+ --enable-strmatch ^
--disable-debug-pack ^
--without-analyzer ^
--enable-object-out-dir=%PHP_BUILD_OBJ_DIR% ^
diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml
index a9bb570f9212..3241fb0fbffa 100644
--- a/.github/workflows/test-suite.yml
+++ b/.github/workflows/test-suite.yml
@@ -767,6 +767,7 @@ jobs:
--disable-mbregex \
--enable-sockets \
--enable-bcmath \
+ --enable-strmatch \
--enable-calendar \
--enable-ftp \
--enable-zend-test \
diff --git a/ext/strmatch/config.m4 b/ext/strmatch/config.m4
new file mode 100644
index 000000000000..88dacd96d349
--- /dev/null
+++ b/ext/strmatch/config.m4
@@ -0,0 +1,11 @@
+PHP_ARG_ENABLE([strmatch],
+ [whether to enable strmatch support],
+ [AS_HELP_STRING([--enable-strmatch],
+ [Enable multi-pattern string matching (Str\Matcher / Str\Replacer)])],
+ [no])
+
+AS_VAR_IF([PHP_STRMATCH], [no],, [
+ AC_DEFINE([HAVE_STRMATCH], [1],
+ [Define to 1 if the PHP extension strmatch is available.])
+ PHP_NEW_EXTENSION([strmatch], [strmatch.c], [$ext_shared])
+])
diff --git a/ext/strmatch/config.w32 b/ext/strmatch/config.w32
new file mode 100644
index 000000000000..689f333e4291
--- /dev/null
+++ b/ext/strmatch/config.w32
@@ -0,0 +1,6 @@
+ARG_ENABLE("strmatch", "multi-pattern string matching support", "no");
+
+if (PHP_STRMATCH != "no") {
+ AC_DEFINE("HAVE_STRMATCH", 1, "Whether strmatch is enabled");
+ EXTENSION("strmatch", "strmatch.c");
+}
diff --git a/ext/strmatch/php_strmatch.h b/ext/strmatch/php_strmatch.h
new file mode 100644
index 000000000000..92adbcea2437
--- /dev/null
+++ b/ext/strmatch/php_strmatch.h
@@ -0,0 +1,25 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Artem Ukrainskiy |
+ +----------------------------------------------------------------------+
+*/
+
+#ifndef PHP_STRMATCH_H
+#define PHP_STRMATCH_H
+
+#include "php.h"
+
+extern zend_module_entry strmatch_module_entry;
+#define phpext_strmatch_ptr &strmatch_module_entry
+
+#define PHP_STRMATCH_VERSION "0.9-dev"
+
+#endif /* PHP_STRMATCH_H */
diff --git a/ext/strmatch/strmatch.c b/ext/strmatch/strmatch.c
new file mode 100644
index 000000000000..6cb3d1982883
--- /dev/null
+++ b/ext/strmatch/strmatch.c
@@ -0,0 +1,1100 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Author: Artem Ukrainskiy |
+ +----------------------------------------------------------------------+
+*/
+
+#ifdef HAVE_CONFIG_H
+# include
+#endif
+
+#include "php.h"
+#include
+#include
+#include "php_strmatch.h"
+#include "Zend/zend_exceptions.h"
+#include "Zend/zend_smart_str.h"
+#include "strmatch_arginfo.h"
+
+#define AC_MAX_STATES ((INT_MAX < INT32_MAX) ? INT_MAX : INT32_MAX)
+#define AC_MATCH_FLAG UINT32_C(0x80000000)
+#define AC_STATE_MASK UINT32_C(0x7fffffff)
+#define AC_INITIAL_CAPACITY 16
+#define AC_DENSE_DEPTH 3
+#define AC_DENSE_MIN_CHILDREN 8
+#define AC_DENSE_FAILURE_WINDOW 4
+
+typedef struct {
+ int fail;
+ int keyword;
+ int output_link;
+
+ union {
+ uint32_t count;
+ int longest;
+ } output;
+ uint32_t link;
+ uint16_t child_count;
+ uint16_t column;
+} ac_build_state;
+
+typedef struct {
+ int keyword;
+ int output_link;
+ uint32_t output_count;
+} ac_state;
+
+typedef struct {
+ int fail;
+ uint32_t link;
+ uint16_t count;
+ uint16_t column;
+} ac_sparse_state;
+
+typedef struct {
+ int target;
+ uint32_t next;
+ uint16_t column;
+} ac_build_edge;
+
+typedef struct {
+ uint32_t target;
+ uint16_t column;
+} ac_edge;
+
+typedef struct {
+ uint32_t *transitions;
+ ac_edge *edges;
+ uint32_t dense_count;
+ ac_build_state *build_states;
+ ac_state *states;
+ ac_sparse_state *sparse;
+ int *longest;
+ int state_count;
+ int capacity;
+ int alphabet_size;
+ uint16_t alphabet[256];
+ zend_string **keywords;
+ zend_string **replacements;
+ uint32_t keyword_count;
+ bool initialized;
+} ac_automaton;
+
+typedef struct {
+ ac_automaton automaton;
+
+ zend_object std;
+} matcher_object;
+
+typedef struct {
+ size_t offset;
+ size_t length;
+ int keyword;
+} ac_hit;
+
+static zend_class_entry *str_matcher_ce;
+static zend_class_entry *str_replacer_ce;
+static zend_class_entry *str_matchresult_ce;
+
+static zend_object_handlers matcher_handlers;
+static uint32_t result_offset, result_length, result_keyword;
+
+static inline matcher_object *matcher_from_object(zend_object *object)
+{
+ return (matcher_object *) ((char *) object - offsetof(matcher_object, std));
+}
+
+/* Consume AC_MATCH_FLAG before reusing a Matcher target as a state ID. */
+static zend_always_inline uint32_t ac_transition(const ac_automaton *automaton, uint32_t state, unsigned char byte)
+{
+ unsigned int column = automaton->alphabet[byte];
+
+ if (column == 0) {
+ return 0;
+ }
+ for (;;) {
+ if (state < automaton->dense_count) {
+ return automaton->transitions[(size_t) state * automaton->alphabet_size + column];
+ }
+ const ac_sparse_state *meta = &automaton->sparse[state - automaton->dense_count];
+ unsigned int count = meta->count;
+
+ if (count == 1) {
+ if (column == meta->column) {
+ return meta->link;
+ }
+ } else if (count > 1) {
+ const ac_edge *edges = automaton->edges + meta->link;
+
+ for (unsigned int i = 0; i < count; i++) {
+ if (edges[i].column == column) {
+ return edges[i].target;
+ }
+ }
+ }
+ state = meta->fail;
+ }
+}
+
+static int ac_add_state(ac_automaton *automaton)
+{
+ if (UNEXPECTED(automaton->state_count == AC_MAX_STATES)) {
+ zend_throw_error(NULL, "Dictionary requires too many matching states");
+ return -1;
+ }
+ if (automaton->state_count == automaton->capacity) {
+ automaton->capacity = automaton->capacity == 0 ? AC_INITIAL_CAPACITY
+ : (automaton->capacity > AC_MAX_STATES / 2 ? AC_MAX_STATES : automaton->capacity * 2);
+ automaton->build_states = safe_erealloc(
+ automaton->build_states, automaton->capacity, sizeof(ac_build_state), 0);
+ }
+
+ int state = automaton->state_count++;
+
+ automaton->build_states[state].fail = 0;
+ automaton->build_states[state].keyword = -1;
+ automaton->build_states[state].output_link = -1;
+ automaton->build_states[state].output.count = 0;
+ return state;
+}
+
+static int ac_build_child(const ac_automaton *automaton, const ac_build_edge *edges, int state, int column)
+{
+ for (uint32_t edge = automaton->build_states[state].link; edge != UINT32_MAX; edge = edges[edge].next) {
+ if (edges[edge].column == column) {
+ return edges[edge].target;
+ }
+ }
+ return -1;
+}
+
+static int ac_build_step(const ac_automaton *automaton, const ac_build_edge *edges, int state, int column)
+{
+ int target;
+
+ while ((target = ac_build_child(automaton, edges, state, column)) < 0 && state != 0) {
+ state = automaton->build_states[state].fail;
+ }
+ return target < 0 ? 0 : target;
+}
+
+static int ac_add_build_state(ac_automaton *automaton)
+{
+ int state = ac_add_state(automaton);
+
+ if (state >= 0) {
+ automaton->build_states[state].link = UINT32_MAX;
+ automaton->build_states[state].column = 0;
+ automaton->build_states[state].child_count = 0;
+ }
+ return state;
+}
+
+static bool ac_use_dense_state(const ac_automaton *automaton, const int *depth, int state)
+{
+ return depth[state] <= AC_DENSE_DEPTH || automaton->build_states[state].child_count >= AC_DENSE_MIN_CHILDREN
+ || (depth[automaton->build_states[state].fail] > AC_DENSE_DEPTH
+ && depth[state] - depth[automaton->build_states[state].fail] <= AC_DENSE_FAILURE_WINDOW);
+}
+
+static zend_result ac_build_trie(ac_automaton *automaton, bool reversed, ac_build_edge **edges_out)
+{
+ int alphabet = 0;
+
+ for (uint32_t k = 0; k < automaton->keyword_count; k++) {
+ zend_string *word = automaton->keywords[k];
+
+ for (size_t i = 0; i < ZSTR_LEN(word); i++) {
+ unsigned char byte = (unsigned char) ZSTR_VAL(word)[i];
+ if (automaton->alphabet[byte] == 0) {
+ automaton->alphabet[byte] = (uint16_t) ++alphabet;
+ }
+ }
+ }
+ automaton->alphabet_size = alphabet + 1;
+ if (ac_add_build_state(automaton) < 0) {
+ return FAILURE;
+ }
+ if (reversed) {
+ automaton->build_states[0].output.longest = -1;
+ }
+ ac_build_edge *build_edges = NULL;
+ uint32_t used = 0, capacity = 0;
+
+ for (uint32_t k = 0; k < automaton->keyword_count; k++) {
+ zend_string *word = automaton->keywords[k];
+ int state = 0;
+
+ for (size_t i = 0; i < ZSTR_LEN(word); i++) {
+ size_t offset = reversed ? ZSTR_LEN(word) - i - 1 : i;
+ int column = automaton->alphabet[(unsigned char) ZSTR_VAL(word)[offset]];
+ int target = ac_build_child(automaton, build_edges, state, column);
+
+ if (target < 0) {
+ target = ac_add_build_state(automaton);
+ if (target < 0) {
+ if (build_edges) {
+ efree(build_edges);
+ }
+ return FAILURE;
+ }
+ if (used == capacity) {
+ capacity = capacity == 0 ? AC_INITIAL_CAPACITY
+ : (capacity > AC_MAX_STATES / 2 ? AC_MAX_STATES : capacity * 2);
+ build_edges = safe_erealloc(build_edges, capacity, sizeof(ac_build_edge), 0);
+ }
+ build_edges[used].column = (uint16_t) column;
+ build_edges[used].target = target;
+ build_edges[used].next = automaton->build_states[state].link;
+ automaton->build_states[state].link = used++;
+ automaton->build_states[state].child_count++;
+ }
+ state = target;
+ }
+ automaton->build_states[state].keyword = (int) k;
+ }
+ automaton->build_states = safe_erealloc(automaton->build_states, automaton->state_count, sizeof(ac_build_state), 0);
+ build_edges = safe_erealloc(build_edges, used, sizeof(ac_build_edge), 0);
+ automaton->capacity = automaton->state_count;
+ *edges_out = build_edges;
+ return SUCCESS;
+}
+
+static void ac_build_failure_links(ac_automaton *automaton, const ac_build_edge *build_edges,
+ int *queue, int *depth, bool reversed)
+{
+ int head = 0, tail = 0;
+
+ for (uint32_t e = automaton->build_states[0].link; e != UINT32_MAX; e = build_edges[e].next) {
+ int child = build_edges[e].target;
+
+ queue[tail++] = child;
+ depth[child] = 1;
+ }
+ while (head < tail) {
+ int state = queue[head++];
+ ac_build_state *meta = &automaton->build_states[state];
+ int fail = meta->fail;
+
+ meta->output_link = automaton->build_states[fail].keyword >= 0
+ ? fail : automaton->build_states[fail].output_link;
+ if (reversed) {
+ int longest = meta->keyword;
+ int inherited = automaton->build_states[fail].output.longest;
+
+ if (inherited >= 0 && (longest < 0
+ || ZSTR_LEN(automaton->keywords[inherited]) > ZSTR_LEN(automaton->keywords[longest]))) {
+ longest = inherited;
+ }
+ meta->output.longest = longest;
+ } else {
+ meta->output.count = (meta->keyword >= 0 ? 1u : 0u) + automaton->build_states[fail].output.count;
+ }
+ for (uint32_t e = meta->link; e != UINT32_MAX; e = build_edges[e].next) {
+ int child = build_edges[e].target;
+
+ automaton->build_states[child].fail = ac_build_step(automaton, build_edges, fail, build_edges[e].column);
+ depth[child] = depth[state] + 1;
+ queue[tail++] = child;
+ }
+ }
+ ZEND_ASSERT(tail == automaton->state_count - 1);
+}
+
+static int ac_map_states(const ac_automaton *automaton, const int *depth, int *state_map,
+ size_t *edge_count)
+{
+ int dense_count = 0;
+ *edge_count = 0;
+
+ for (int state = 0; state < automaton->state_count; state++) {
+ if (ac_use_dense_state(automaton, depth, state)) {
+ state_map[state] = dense_count++;
+ } else {
+ state_map[state] = -1;
+ if (automaton->build_states[state].child_count > 1) {
+ *edge_count += automaton->build_states[state].child_count;
+ }
+ }
+ }
+ int next = dense_count;
+
+ for (int state = 0; state < automaton->state_count; state++) {
+ if (state_map[state] < 0) {
+ state_map[state] = next++;
+ }
+ }
+
+ return dense_count;
+}
+
+/* Cache keys use mapped sparse IDs minus dense_count; values are mapped targets. */
+static int ac_resolve_transition(const ac_automaton *automaton, const ac_build_edge *build_edges,
+ const int *state_map, int dense_count, int *cache, int state, int column)
+{
+ int first = state;
+ int target;
+
+ for (;;) {
+ int mapped_state = state_map[state];
+
+ if (mapped_state < dense_count) {
+ target = automaton->transitions[(size_t) mapped_state * automaton->alphabet_size + column];
+ break;
+ }
+ if (cache[mapped_state - dense_count] >= 0) {
+ target = cache[mapped_state - dense_count];
+ break;
+ }
+ int child = ac_build_child(automaton, build_edges, state, column);
+
+ if (child >= 0) {
+ target = state_map[child];
+ cache[mapped_state - dense_count] = target;
+ break;
+ }
+ state = automaton->build_states[state].fail;
+ }
+ while (first != state) {
+ ZEND_ASSERT(state_map[first] >= dense_count);
+ cache[state_map[first] - dense_count] = target;
+ first = automaton->build_states[first].fail;
+ }
+ return target;
+}
+
+/* The current column is completed in BFS order before the cache is reused. */
+static void ac_compile_dense(const ac_automaton *automaton, const ac_build_edge *build_edges,
+ int *queue, const int *state_map, int dense_count, int *cache)
+{
+ int state_count = automaton->state_count;
+ int dense_states = 0;
+ bool needs_cache = false;
+
+ /* Filtering preserves the BFS order of all dense rows. */
+ for (int position = 0; position < state_count - 1; position++) {
+ int state = queue[position];
+
+ int mapped_state = state_map[state];
+
+ if (mapped_state < dense_count) {
+ queue[dense_states++] = state;
+ if (state_map[automaton->build_states[state].fail] >= dense_count) {
+ needs_cache = true;
+ }
+ }
+ }
+ ZEND_ASSERT(dense_states == dense_count - 1);
+
+ for (int column = 0; column < automaton->alphabet_size; column++) {
+ if (column != 0 && needs_cache) {
+ for (int state = 0; state < state_count - dense_count; state++) {
+ cache[state] = -1;
+ }
+ }
+ for (int position = -1; position < dense_states; position++) {
+ int old = position < 0 ? 0 : queue[position];
+ int state = state_map[old];
+
+ int target = 0;
+
+ if (column != 0) {
+ int child = ac_build_child(automaton, build_edges, old, column);
+
+ if (child >= 0) {
+ target = state_map[child];
+ } else if (old != 0) {
+ target = ac_resolve_transition(automaton, build_edges, state_map,
+ dense_count, cache, automaton->build_states[old].fail, column);
+ }
+ }
+ automaton->transitions[(size_t) state * automaton->alphabet_size + column] = target;
+ }
+ }
+}
+
+static void ac_pack_edges(ac_automaton *automaton, const ac_build_edge *build_edges,
+ const int *state_map, int dense_count, ac_edge *edges, size_t edge_count)
+{
+ size_t edge = 0;
+
+ for (int old = 0; old < automaton->state_count; old++) {
+ int state = state_map[old];
+ ac_build_state *meta = &automaton->build_states[old];
+
+ if (state >= dense_count) {
+ uint32_t first = meta->link;
+
+ meta->link = (uint32_t) edge;
+ for (uint32_t e = first; e != UINT32_MAX; e = build_edges[e].next) {
+ if (meta->child_count == 1) {
+ meta->column = build_edges[e].column;
+ meta->link = (uint32_t) state_map[build_edges[e].target];
+ } else {
+ edges[edge].column = build_edges[e].column;
+ edges[edge++].target = state_map[build_edges[e].target];
+ }
+ }
+ }
+ meta->fail = state_map[meta->fail];
+ if (meta->output_link >= 0) {
+ meta->output_link = state_map[meta->output_link];
+ }
+ }
+ ZEND_ASSERT(edge == edge_count);
+}
+
+static void ac_permute_build_states(ac_automaton *automaton, int *state_map)
+{
+ /* Map is a permutation; use its sign to visit every cycle exactly once. */
+ for (int start = 0; start < automaton->state_count; start++) {
+ if (state_map[start] < 0) {
+ continue;
+ }
+ ac_build_state held = automaton->build_states[start];
+ int current = start;
+
+ do {
+ int destination = state_map[current];
+
+ ZEND_ASSERT(destination >= 0);
+ ac_build_state previous = automaton->build_states[destination];
+
+ automaton->build_states[destination] = held;
+ held = previous;
+ state_map[current] = -destination - 1;
+ current = destination;
+ } while (current != start);
+ }
+}
+
+static void ac_pack_states(ac_automaton *automaton, int dense_count, bool reversed)
+{
+ int sparse_count = automaton->state_count - dense_count;
+
+ automaton->sparse = sparse_count ? safe_emalloc(sparse_count, sizeof(ac_sparse_state), 0) : NULL;
+ if (reversed) {
+ automaton->longest = safe_emalloc(automaton->state_count, sizeof(int), 0);
+ } else {
+ automaton->states = safe_emalloc(automaton->state_count, sizeof(ac_state), 0);
+ }
+ for (int state = 0; state < automaton->state_count; state++) {
+ const ac_build_state *meta = &automaton->build_states[state];
+
+ if (state >= dense_count) {
+ ac_sparse_state *info = &automaton->sparse[state - dense_count];
+
+ info->fail = meta->fail;
+ info->link = meta->link;
+ info->count = meta->child_count;
+ info->column = meta->column;
+ }
+ if (reversed) {
+ automaton->longest[state] = meta->output.longest;
+ } else {
+ automaton->states[state].keyword = meta->keyword;
+ automaton->states[state].output_link = meta->output_link;
+ automaton->states[state].output_count = meta->output.count;
+ }
+ }
+ efree(automaton->build_states);
+ automaton->build_states = NULL;
+ automaton->dense_count = (uint32_t) dense_count;
+}
+
+static void ac_mark_outputs(ac_automaton *automaton, size_t edge_count)
+{
+ /* State IDs fit AC_MAX_STATES. Match-any can test an output bit without a metadata load. */
+ size_t cells = (size_t) automaton->dense_count * automaton->alphabet_size;
+
+ for (size_t i = 0; i < cells; i++) {
+ uint32_t target = automaton->transitions[i];
+
+ if (automaton->states[target].output_count) {
+ automaton->transitions[i] = target | AC_MATCH_FLAG;
+ }
+ }
+ for (size_t i = 0; i < edge_count; i++) {
+ uint32_t target = automaton->edges[i].target;
+
+ if (automaton->states[target].output_count) {
+ automaton->edges[i].target = target | AC_MATCH_FLAG;
+ }
+ }
+ for (int i = 0; i < automaton->state_count - (int) automaton->dense_count; i++) {
+ if (automaton->sparse[i].count == 1) {
+ uint32_t target = automaton->sparse[i].link;
+
+ if (automaton->states[target].output_count) {
+ automaton->sparse[i].link = target | AC_MATCH_FLAG;
+ }
+ }
+ }
+}
+
+static zend_result ac_build(ac_automaton *automaton, bool reversed)
+{
+ if (automaton->keyword_count == 0) {
+ return SUCCESS;
+ }
+
+ ac_build_edge *build_edges = NULL;
+
+ if (ac_build_trie(automaton, reversed, &build_edges) == FAILURE) {
+ return FAILURE;
+ }
+
+ int *queue = safe_emalloc(automaton->state_count, sizeof(int), 0);
+ int *depth = ecalloc(automaton->state_count, sizeof(int));
+ int *state_map = safe_emalloc(automaton->state_count, sizeof(int), 0);
+ size_t edge_count;
+
+ ac_build_failure_links(automaton, build_edges, queue, depth, reversed);
+ int dense_count = ac_map_states(automaton, depth, state_map, &edge_count);
+
+ automaton->transitions = safe_emalloc(dense_count, automaton->alphabet_size * sizeof(uint32_t), 0);
+ automaton->edges = edge_count ? safe_emalloc(edge_count, sizeof(ac_edge), 0) : NULL;
+ /* Column caching reuses the completed depth workspace. */
+ ac_compile_dense(automaton, build_edges, queue, state_map, dense_count, depth);
+ efree(queue);
+ efree(depth);
+
+ ac_pack_edges(automaton, build_edges, state_map, dense_count, automaton->edges, edge_count);
+ efree(build_edges);
+ ac_permute_build_states(automaton, state_map);
+ efree(state_map);
+ ac_pack_states(automaton, dense_count, reversed);
+ if (!reversed) {
+ ac_mark_outputs(automaton, edge_count);
+ }
+ return SUCCESS;
+}
+
+static void ac_destroy(ac_automaton *automaton)
+{
+ for (uint32_t i = 0; i < automaton->keyword_count; i++) {
+ zend_string_release(automaton->keywords[i]);
+ if (automaton->replacements) {
+ zend_string_release(automaton->replacements[i]);
+ }
+ }
+ if (automaton->keywords) {
+ efree(automaton->keywords);
+ }
+ if (automaton->replacements) {
+ efree(automaton->replacements);
+ }
+ if (automaton->transitions) {
+ efree(automaton->transitions);
+ }
+ if (automaton->edges) {
+ efree(automaton->edges);
+ }
+ if (automaton->build_states) {
+ efree(automaton->build_states);
+ }
+ if (automaton->sparse) {
+ efree(automaton->sparse);
+ }
+ if (automaton->longest) {
+ efree(automaton->longest);
+ }
+ if (automaton->states) {
+ efree(automaton->states);
+ }
+}
+
+static zend_object *matcher_create(zend_class_entry *ce)
+{
+ matcher_object *object = zend_object_alloc(sizeof(matcher_object), ce);
+ zend_object_std_init(&object->std, ce);
+ object_properties_init(&object->std, ce);
+ return &object->std;
+}
+
+static void matcher_free(zend_object *object)
+{
+ ac_destroy(&matcher_from_object(object)->automaton);
+ zend_object_std_dtor(object);
+}
+
+static zend_result matcher_check_uninitialized(ac_automaton *automaton, zend_class_entry *ce)
+{
+ if (UNEXPECTED(automaton->initialized)) {
+ zend_throw_error(NULL, "%pS is already initialized", ce->name);
+ return FAILURE;
+ }
+ return SUCCESS;
+}
+
+PHP_METHOD(Str_Matcher, __construct)
+{
+ HashTable *keywords;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_ARRAY_HT(keywords)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+
+ if (matcher_check_uninitialized(automaton, Z_OBJCE_P(ZEND_THIS)) == FAILURE) {
+ RETURN_THROWS();
+ }
+
+ ac_automaton prepared = {0};
+ uint32_t count = zend_hash_num_elements(keywords);
+
+ if (count) {
+ prepared.keywords = safe_emalloc(count, sizeof(zend_string *), 0);
+ }
+ HashTable seen;
+
+ zend_hash_init(&seen, count, NULL, NULL, false);
+ zval *entry;
+
+ ZEND_HASH_FOREACH_VAL(keywords, entry) {
+ ZVAL_DEREF(entry);
+ if (Z_TYPE_P(entry) != IS_STRING) {
+ zend_argument_type_error(1, "must be an array of strings");
+ goto failure;
+ }
+ if (Z_STRLEN_P(entry) == 0) {
+ zend_argument_value_error(1, "must not contain an empty keyword");
+ goto failure;
+ }
+ if (zend_hash_add_empty_element(&seen, Z_STR_P(entry))) {
+ prepared.keywords[prepared.keyword_count++] = zend_string_copy(Z_STR_P(entry));
+ }
+ } ZEND_HASH_FOREACH_END();
+ zend_hash_destroy(&seen);
+ if (prepared.keyword_count < count) {
+ prepared.keywords = safe_erealloc(prepared.keywords, prepared.keyword_count, sizeof(zend_string *), 0);
+ }
+
+ if (ac_build(&prepared, false) == FAILURE) {
+ ac_destroy(&prepared);
+ RETURN_THROWS();
+ }
+ prepared.initialized = true;
+ *automaton = prepared;
+ return;
+
+failure:
+ zend_hash_destroy(&seen);
+ ac_destroy(&prepared);
+ RETURN_THROWS();
+}
+
+PHP_METHOD(Str_Matcher, matches)
+{
+ zend_string *subject;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(subject)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+
+ if (automaton->keyword_count == 0) {
+ RETURN_FALSE;
+ }
+ uint32_t state = 0;
+
+ for (size_t i = 0; i < ZSTR_LEN(subject); i++) {
+ state = ac_transition(automaton, state, (unsigned char) ZSTR_VAL(subject)[i]);
+ if (state & AC_MATCH_FLAG) {
+ RETURN_TRUE;
+ }
+ }
+ RETURN_FALSE;
+}
+
+static zend_always_inline bool ac_count_add(zend_long *count, uint32_t increment)
+{
+ if (UNEXPECTED(increment > ZEND_LONG_MAX - *count)) {
+ return false;
+ }
+ *count += increment;
+ return true;
+}
+
+PHP_METHOD(Str_Matcher, countMatches)
+{
+ zend_string *subject;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(subject)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+ zend_long count = 0;
+
+ if (automaton->keyword_count == 0) {
+ RETURN_LONG(0);
+ }
+ uint32_t state = 0;
+
+ for (size_t i = 0; i < ZSTR_LEN(subject); i++) {
+ state = ac_transition(automaton, state, (unsigned char) ZSTR_VAL(subject)[i]);
+ if (!(state & AC_MATCH_FLAG)) {
+ continue;
+ }
+ state &= AC_STATE_MASK;
+ if (UNEXPECTED(!ac_count_add(&count, automaton->states[state].output_count))) {
+ zend_throw_error(NULL, "Match count exceeds PHP_INT_MAX");
+ RETURN_THROWS();
+ }
+ }
+ RETURN_LONG(count);
+}
+
+static int ac_hit_compare(const void *a, const void *b)
+{
+ const ac_hit *left = a, *right = b;
+
+ if (left->offset != right->offset) {
+ return left->offset < right->offset ? -1 : 1;
+ }
+ return (left->length > right->length) - (left->length < right->length);
+}
+
+static zend_result ac_record_hit(ac_hit **hits, size_t *count, size_t *capacity,
+ size_t offset, int keyword, zend_string *word)
+{
+ if (UNEXPECTED(*count == HT_MAX_SIZE)) {
+ zend_throw_error(NULL, "Match result exceeds the maximum array size");
+ return FAILURE;
+ }
+ if (*count == *capacity) {
+ *capacity = *capacity ? *capacity * 2 : AC_INITIAL_CAPACITY;
+ *hits = safe_erealloc(*hits, *capacity, sizeof(ac_hit), 0);
+ }
+ (*hits)[*count].offset = offset;
+ (*hits)[*count].length = ZSTR_LEN(word);
+ (*hits)[*count].keyword = keyword;
+ (*count)++;
+ return SUCCESS;
+}
+
+PHP_METHOD(Str_Matcher, getMatchedKeywords)
+{
+ zend_string *subject;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(subject)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+
+ array_init(return_value);
+ if (automaton->keyword_count == 0 || ZSTR_LEN(subject) == 0) {
+ return;
+ }
+ bool *seen = ecalloc(automaton->keyword_count, sizeof(bool));
+ ac_hit *first = NULL;
+ size_t count = 0, capacity = 0;
+ uint32_t state = 0;
+
+ for (size_t i = 0; i < ZSTR_LEN(subject) && count < automaton->keyword_count; i++) {
+ state = ac_transition(automaton, state, (unsigned char) ZSTR_VAL(subject)[i]);
+ if (!(state & AC_MATCH_FLAG)) {
+ continue;
+ }
+ state &= AC_STATE_MASK;
+ int output = automaton->states[state].keyword >= 0 ? (int) state : automaton->states[state].output_link;
+
+ while (output >= 0) {
+ int keyword = automaton->states[output].keyword;
+ /* A visited terminal and all its output ancestors have already been recorded. */
+ if (seen[keyword]) {
+ break;
+ }
+ seen[keyword] = true;
+ zend_string *word = automaton->keywords[keyword];
+
+ if (ac_record_hit(&first, &count, &capacity, i + 1 - ZSTR_LEN(word), keyword, word) == FAILURE) {
+ efree(seen);
+ if (first) {
+ efree(first);
+ }
+ RETURN_THROWS();
+ }
+ output = automaton->states[output].output_link;
+ }
+ }
+ efree(seen);
+ if (count > 1) {
+ qsort(first, count, sizeof(ac_hit), ac_hit_compare);
+ }
+ for (size_t i = 0; i < count; i++) {
+ add_next_index_str(return_value, zend_string_copy(automaton->keywords[first[i].keyword]));
+ }
+ if (first) {
+ efree(first);
+ }
+}
+
+PHP_METHOD(Str_Matcher, matchAll)
+{
+ zend_string *subject;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_STR(subject)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+
+ array_init(return_value);
+ if (automaton->keyword_count == 0 || ZSTR_LEN(subject) == 0) {
+ return;
+ }
+ ac_hit *hits = NULL;
+ size_t count = 0, capacity = 0;
+ uint32_t state = 0;
+
+ for (size_t i = 0; i < ZSTR_LEN(subject); i++) {
+ state = ac_transition(automaton, state, (unsigned char) ZSTR_VAL(subject)[i]);
+ if (!(state & AC_MATCH_FLAG)) {
+ continue;
+ }
+ state &= AC_STATE_MASK;
+ int output = automaton->states[state].keyword >= 0 ? (int) state : automaton->states[state].output_link;
+
+ while (output >= 0) {
+ int keyword = automaton->states[output].keyword;
+ zend_string *word = automaton->keywords[keyword];
+
+ if (ac_record_hit(&hits, &count, &capacity, i + 1 - ZSTR_LEN(word), keyword, word) == FAILURE) {
+ if (hits) {
+ efree(hits);
+ }
+ RETURN_THROWS();
+ }
+ output = automaton->states[output].output_link;
+ }
+ }
+ if (count > 1) {
+ qsort(hits, count, sizeof(ac_hit), ac_hit_compare);
+ }
+ if (count > 0 && UNEXPECTED(hits[count - 1].offset > (size_t) ZEND_LONG_MAX)) {
+ efree(hits);
+ zend_throw_error(NULL, "Match offset exceeds PHP_INT_MAX");
+ RETURN_THROWS();
+ }
+ for (size_t i = 0; i < count; i++) {
+ zval result;
+
+ object_init_ex(&result, str_matchresult_ce);
+ zend_object *object = Z_OBJ(result);
+ ZVAL_LONG(OBJ_PROP(object, result_offset), (zend_long) hits[i].offset);
+ ZVAL_LONG(OBJ_PROP(object, result_length), (zend_long) hits[i].length);
+ ZVAL_STR(OBJ_PROP(object, result_keyword), zend_string_copy(automaton->keywords[hits[i].keyword]));
+ add_next_index_zval(return_value, &result);
+ }
+ if (hits) {
+ efree(hits);
+ }
+}
+
+PHP_METHOD(Str_Matcher, getKeywords)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+
+ array_init_size(return_value, automaton->keyword_count);
+ for (uint32_t i = 0; i < automaton->keyword_count; i++) {
+ add_next_index_str(return_value, zend_string_copy(automaton->keywords[i]));
+ }
+}
+
+PHP_METHOD(Str_MatchResult, __construct)
+{
+ zend_throw_error(NULL, "Cannot directly construct Str\\MatchResult, use Str\\Matcher::matchAll() instead");
+ RETURN_THROWS();
+}
+
+PHP_METHOD(Str_Replacer, __construct)
+{
+ HashTable *replacements;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_ARRAY_HT(replacements)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+
+ if (matcher_check_uninitialized(automaton, Z_OBJCE_P(ZEND_THIS)) == FAILURE) {
+ RETURN_THROWS();
+ }
+ ac_automaton prepared = {0};
+ uint32_t count = zend_hash_num_elements(replacements);
+
+ if (count) {
+ prepared.keywords = safe_emalloc(count, sizeof(zend_string *), 0);
+ prepared.replacements = safe_emalloc(count, sizeof(zend_string *), 0);
+ }
+ zend_string *key;
+ zend_ulong index;
+ zval *value;
+
+ ZEND_HASH_FOREACH_KEY_VAL(replacements, index, key, value) {
+ ZVAL_DEREF(value);
+ if (Z_TYPE_P(value) != IS_STRING) {
+ zend_argument_type_error(1, "must be an array of string replacements");
+ goto failure;
+ }
+ if (key && ZSTR_LEN(key) == 0) {
+ zend_argument_value_error(1, "must not contain an empty keyword");
+ goto failure;
+ }
+ prepared.keywords[prepared.keyword_count] = key ? zend_string_copy(key) : zend_long_to_str((zend_long) index);
+ prepared.replacements[prepared.keyword_count] = zend_string_copy(Z_STR_P(value));
+ prepared.keyword_count++;
+ } ZEND_HASH_FOREACH_END();
+
+ if (ac_build(&prepared, true) == FAILURE) {
+ goto failure;
+ }
+ prepared.initialized = true;
+ *automaton = prepared;
+ return;
+
+failure:
+ ac_destroy(&prepared);
+ RETURN_THROWS();
+}
+
+/* The reversed automaton gives the longest keyword at each original start position. */
+PHP_METHOD(Str_Replacer, replace)
+{
+ zend_string *subject;
+ zval *replacement_count = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_STR(subject)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_ZVAL(replacement_count)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+ int *best = NULL;
+ size_t candidates = 0;
+
+ if (automaton->keyword_count) {
+ uint32_t state = 0;
+
+ for (size_t i = ZSTR_LEN(subject); i > 0;) {
+ i--;
+ state = ac_transition(automaton, state, (unsigned char) ZSTR_VAL(subject)[i]);
+ int keyword = automaton->longest[state];
+
+ if (keyword >= 0 && best == NULL) {
+ candidates = i + 1;
+ best = safe_emalloc(candidates, sizeof(int), 0);
+ }
+ if (best) {
+ best[i] = keyword;
+ }
+ }
+ }
+ if (best == NULL) {
+ RETVAL_STR_COPY(subject);
+ if (replacement_count) {
+ ZEND_TRY_ASSIGN_REF_LONG(replacement_count, 0);
+ }
+ return;
+ }
+
+ smart_str output = {0};
+ zend_long count = 0;
+ size_t position = 0, literal = 0;
+
+ while (position < candidates) {
+ int keyword = best[position];
+
+ if (keyword < 0) {
+ position++;
+ continue;
+ }
+ if (position > literal) {
+ smart_str_appendl(&output, ZSTR_VAL(subject) + literal, position - literal);
+ }
+ smart_str_append(&output, automaton->replacements[keyword]);
+ position += ZSTR_LEN(automaton->keywords[keyword]);
+ literal = position;
+ count++;
+ }
+ if (ZSTR_LEN(subject) > literal) {
+ smart_str_appendl(&output, ZSTR_VAL(subject) + literal, ZSTR_LEN(subject) - literal);
+ }
+ efree(best);
+ smart_str_0(&output);
+ RETVAL_STR(output.s ? output.s : ZSTR_EMPTY_ALLOC());
+ if (replacement_count) {
+ ZEND_TRY_ASSIGN_REF_LONG(replacement_count, count);
+ }
+}
+
+PHP_METHOD(Str_Replacer, getReplacements)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+ ac_automaton *automaton = &matcher_from_object(Z_OBJ_P(ZEND_THIS))->automaton;
+
+ array_init_size(return_value, automaton->keyword_count);
+ for (uint32_t i = 0; i < automaton->keyword_count; i++) {
+ zval replacement;
+
+ ZVAL_STR(&replacement, zend_string_copy(automaton->replacements[i]));
+ zend_symtable_update(Z_ARRVAL_P(return_value), automaton->keywords[i], &replacement);
+ }
+}
+
+PHP_MINIT_FUNCTION(strmatch)
+{
+ str_matchresult_ce = register_class_Str_MatchResult();
+ result_offset = ((zend_property_info *) zend_hash_str_find_ptr(
+ &str_matchresult_ce->properties_info, "offset", strlen("offset")))->offset;
+ result_length = ((zend_property_info *) zend_hash_str_find_ptr(
+ &str_matchresult_ce->properties_info, "length", strlen("length")))->offset;
+ result_keyword = ((zend_property_info *) zend_hash_str_find_ptr(
+ &str_matchresult_ce->properties_info, "keyword", strlen("keyword")))->offset;
+
+ memcpy(&matcher_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
+ matcher_handlers.offset = offsetof(matcher_object, std);
+ matcher_handlers.free_obj = matcher_free;
+ matcher_handlers.clone_obj = NULL;
+ matcher_handlers.compare = zend_objects_not_comparable;
+
+ str_matcher_ce = register_class_Str_Matcher();
+ str_matcher_ce->create_object = matcher_create;
+ str_matcher_ce->default_object_handlers = &matcher_handlers;
+ str_replacer_ce = register_class_Str_Replacer();
+ str_replacer_ce->create_object = matcher_create;
+ str_replacer_ce->default_object_handlers = &matcher_handlers;
+ return SUCCESS;
+}
+
+zend_module_entry strmatch_module_entry = {
+ STANDARD_MODULE_HEADER,
+ "strmatch",
+ NULL,
+ PHP_MINIT(strmatch),
+ NULL,
+ NULL,
+ NULL,
+ NULL,
+ PHP_STRMATCH_VERSION,
+ STANDARD_MODULE_PROPERTIES
+};
+
+#ifdef COMPILE_DL_STRMATCH
+ZEND_GET_MODULE(strmatch)
+#endif
diff --git a/ext/strmatch/strmatch.stub.php b/ext/strmatch/strmatch.stub.php
new file mode 100644
index 000000000000..34fbe6ca1500
--- /dev/null
+++ b/ext/strmatch/strmatch.stub.php
@@ -0,0 +1,52 @@
+ */
+ public function getMatchedKeywords(string $subject): array {}
+
+ /** @return list<\Str\MatchResult> */
+ public function matchAll(string $subject): array {}
+
+ /** @return list */
+ public function getKeywords(): array {}
+}
+
+/** @strict-properties */
+final class MatchResult
+{
+ private function __construct() {}
+
+ public readonly int $offset;
+ public readonly int $length;
+ public readonly string $keyword;
+}
+
+/**
+ * @strict-properties
+ * @not-serializable
+ */
+final class Replacer
+{
+ public function __construct(array $replacements) {}
+
+ /** @param int $count */
+ public function replace(string $subject, &$count = null): string {}
+
+ /** @return array */
+ public function getReplacements(): array {}
+}
diff --git a/ext/strmatch/strmatch_arginfo.h b/ext/strmatch/strmatch_arginfo.h
new file mode 100644
index 000000000000..34b8d64bca34
--- /dev/null
+++ b/ext/strmatch/strmatch_arginfo.h
@@ -0,0 +1,118 @@
+/* This is a generated file, edit strmatch.stub.php instead.
+ * Stub hash: a9596ed4be91cbd0089cf33773381e66261de065 */
+
+ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Str_Matcher___construct, 0, 0, 1)
+ ZEND_ARG_TYPE_INFO(0, keywords, IS_ARRAY, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Str_Matcher_matches, 0, 1, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_INFO(0, subject, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Str_Matcher_countMatches, 0, 1, IS_LONG, 0)
+ ZEND_ARG_TYPE_INFO(0, subject, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Str_Matcher_getMatchedKeywords, 0, 1, IS_ARRAY, 0)
+ ZEND_ARG_TYPE_INFO(0, subject, IS_STRING, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_Str_Matcher_matchAll arginfo_class_Str_Matcher_getMatchedKeywords
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Str_Matcher_getKeywords, 0, 0, IS_ARRAY, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Str_MatchResult___construct, 0, 0, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Str_Replacer___construct, 0, 0, 1)
+ ZEND_ARG_TYPE_INFO(0, replacements, IS_ARRAY, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Str_Replacer_replace, 0, 1, IS_STRING, 0)
+ ZEND_ARG_TYPE_INFO(0, subject, IS_STRING, 0)
+ ZEND_ARG_INFO_WITH_DEFAULT_VALUE(1, count, "null")
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_Str_Replacer_getReplacements arginfo_class_Str_Matcher_getKeywords
+
+ZEND_METHOD(Str_Matcher, __construct);
+ZEND_METHOD(Str_Matcher, matches);
+ZEND_METHOD(Str_Matcher, countMatches);
+ZEND_METHOD(Str_Matcher, getMatchedKeywords);
+ZEND_METHOD(Str_Matcher, matchAll);
+ZEND_METHOD(Str_Matcher, getKeywords);
+ZEND_METHOD(Str_MatchResult, __construct);
+ZEND_METHOD(Str_Replacer, __construct);
+ZEND_METHOD(Str_Replacer, replace);
+ZEND_METHOD(Str_Replacer, getReplacements);
+
+static const zend_function_entry class_Str_Matcher_methods[] = {
+ ZEND_ME(Str_Matcher, __construct, arginfo_class_Str_Matcher___construct, ZEND_ACC_PUBLIC)
+ ZEND_ME(Str_Matcher, matches, arginfo_class_Str_Matcher_matches, ZEND_ACC_PUBLIC)
+ ZEND_ME(Str_Matcher, countMatches, arginfo_class_Str_Matcher_countMatches, ZEND_ACC_PUBLIC)
+ ZEND_ME(Str_Matcher, getMatchedKeywords, arginfo_class_Str_Matcher_getMatchedKeywords, ZEND_ACC_PUBLIC)
+ ZEND_ME(Str_Matcher, matchAll, arginfo_class_Str_Matcher_matchAll, ZEND_ACC_PUBLIC)
+ ZEND_ME(Str_Matcher, getKeywords, arginfo_class_Str_Matcher_getKeywords, ZEND_ACC_PUBLIC)
+ ZEND_FE_END
+};
+
+static const zend_function_entry class_Str_MatchResult_methods[] = {
+ ZEND_ME(Str_MatchResult, __construct, arginfo_class_Str_MatchResult___construct, ZEND_ACC_PRIVATE)
+ ZEND_FE_END
+};
+
+static const zend_function_entry class_Str_Replacer_methods[] = {
+ ZEND_ME(Str_Replacer, __construct, arginfo_class_Str_Replacer___construct, ZEND_ACC_PUBLIC)
+ ZEND_ME(Str_Replacer, replace, arginfo_class_Str_Replacer_replace, ZEND_ACC_PUBLIC)
+ ZEND_ME(Str_Replacer, getReplacements, arginfo_class_Str_Replacer_getReplacements, ZEND_ACC_PUBLIC)
+ ZEND_FE_END
+};
+
+static zend_class_entry *register_class_Str_Matcher(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "Str", "Matcher", class_Str_Matcher_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE);
+
+ return class_entry;
+}
+
+static zend_class_entry *register_class_Str_MatchResult(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "Str", "MatchResult", class_Str_MatchResult_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES);
+
+ zval property_offset_default_value;
+ ZVAL_UNDEF(&property_offset_default_value);
+ zend_string *property_offset_name = zend_string_init("offset", sizeof("offset") - 1, true);
+ zend_declare_typed_property(class_entry, property_offset_name, &property_offset_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG));
+ zend_string_release_ex(property_offset_name, true);
+
+ zval property_length_default_value;
+ ZVAL_UNDEF(&property_length_default_value);
+ zend_string *property_length_name = zend_string_init("length", sizeof("length") - 1, true);
+ zend_declare_typed_property(class_entry, property_length_name, &property_length_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG));
+ zend_string_release_ex(property_length_name, true);
+
+ zval property_keyword_default_value;
+ ZVAL_UNDEF(&property_keyword_default_value);
+ zend_string *property_keyword_name = zend_string_init("keyword", sizeof("keyword") - 1, true);
+ zend_declare_typed_property(class_entry, property_keyword_name, &property_keyword_default_value, ZEND_ACC_PUBLIC|ZEND_ACC_READONLY, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING));
+ zend_string_release_ex(property_keyword_name, true);
+
+ return class_entry;
+}
+
+static zend_class_entry *register_class_Str_Replacer(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "Str", "Replacer", class_Str_Replacer_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE);
+
+ return class_entry;
+}
diff --git a/ext/strmatch/tests/001-matcher.phpt b/ext/strmatch/tests/001-matcher.phpt
new file mode 100644
index 000000000000..b29993afa130
--- /dev/null
+++ b/ext/strmatch/tests/001-matcher.phpt
@@ -0,0 +1,73 @@
+--TEST--
+Str\Matcher: methods, start order, empty inputs and dictionary deduplication
+--EXTENSIONS--
+strmatch
+--FILE--
+matches('ushers'), $m->matches('nothing'));
+var_dump($m->countMatches('ushers'));
+print_r($m->getMatchedKeywords('ushers'));
+foreach ($m->matchAll('ushers') as $r) {
+ printf("%d:%d:%s\n", $r->offset, $r->length, $r->keyword);
+}
+print_r($m->getKeywords());
+
+// Start order differs from the order in which matches end.
+print_r((new Str\Matcher(['abcd', 'bc']))->getMatchedKeywords('abcd'));
+foreach ([[], ['word']] as $dictionary) {
+ $matcher = new Str\Matcher($dictionary);
+ $subjects = $dictionary === [] ? ['', 'word'] : [''];
+ foreach ($subjects as $subject) {
+ $actual = [
+ $matcher->matches($subject),
+ $matcher->countMatches($subject),
+ $matcher->getMatchedKeywords($subject),
+ $matcher->matchAll($subject),
+ $matcher->getKeywords(),
+ ];
+ if ($actual !== [false, 0, [], [], $dictionary]) {
+ var_dump($actual);
+ throw new Exception('Empty input mismatch');
+ }
+ }
+}
+echo "Empty inputs OK\n";
+$matcher = new Str\Matcher(['b', 'a', 'b', 'c', 'a']);
+$hits = array_map(static fn ($hit) => [$hit->offset, $hit->length, $hit->keyword], $matcher->matchAll('abca'));
+if ($matcher->getKeywords() !== ['b', 'a', 'c']
+ || $matcher->getMatchedKeywords('abca') !== ['a', 'b', 'c']
+ || $matcher->countMatches('abca') !== 4
+ || !$matcher->matches('abca')
+ || $hits !== [[0, 1, 'a'], [1, 1, 'b'], [2, 1, 'c'], [3, 1, 'a']]) {
+ throw new Exception('Deduplication mismatch');
+}
+echo "Deduplication OK\n";
+?>
+--EXPECT--
+bool(true)
+bool(false)
+int(3)
+Array
+(
+ [0] => she
+ [1] => he
+ [2] => hers
+)
+1:3:she
+2:2:he
+2:4:hers
+Array
+(
+ [0] => he
+ [1] => she
+ [2] => his
+ [3] => hers
+)
+Array
+(
+ [0] => abcd
+ [1] => bc
+)
+Empty inputs OK
+Deduplication OK
diff --git a/ext/strmatch/tests/002-replacer.phpt b/ext/strmatch/tests/002-replacer.phpt
new file mode 100644
index 000000000000..b2a086e6d236
--- /dev/null
+++ b/ext/strmatch/tests/002-replacer.phpt
@@ -0,0 +1,16 @@
+--TEST--
+Str\Replacer: replace with strtr semantics (longest-match, non-overlapping) and count
+--EXTENSIONS--
+strmatch
+--FILE--
+ 'dog', 'cats' => 'DOGS', 'a' => 'A']);
+$n = 0;
+var_dump($r->replace('a cat and cats', $n));
+var_dump($n);
+var_dump($r->replace('nothing here'));
+?>
+--EXPECT--
+string(14) "A dog And DOGS"
+int(4)
+string(12) "nothing here"
diff --git a/ext/strmatch/tests/003-errors.phpt b/ext/strmatch/tests/003-errors.phpt
new file mode 100644
index 000000000000..34481c04f754
--- /dev/null
+++ b/ext/strmatch/tests/003-errors.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Str constructors reject empty keywords and non-string dictionary values
+--EXTENSIONS--
+strmatch
+--FILE--
+ 'value', '' => 'empty']],
+ [Str\Replacer::class, ['ok' => 'value', 'bad' => 123]],
+] as [$class, $dictionary]) {
+ try {
+ new $class($dictionary);
+ echo "FAIL\n";
+ } catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+?>
+--EXPECT--
+ValueError: Str\Matcher::__construct(): Argument #1 ($keywords) must not contain an empty keyword
+TypeError: Str\Matcher::__construct(): Argument #1 ($keywords) must be an array of strings
+ValueError: Str\Replacer::__construct(): Argument #1 ($replacements) must not contain an empty keyword
+TypeError: Str\Replacer::__construct(): Argument #1 ($replacements) must be an array of string replacements
diff --git a/ext/strmatch/tests/004-binary.phpt b/ext/strmatch/tests/004-binary.phpt
new file mode 100644
index 000000000000..9eac296f27b5
--- /dev/null
+++ b/ext/strmatch/tests/004-binary.phpt
@@ -0,0 +1,63 @@
+--TEST--
+Str matching is byte-based: UTF-8, embedded NUL, high bytes and the full alphabet
+--EXTENSIONS--
+strmatch
+--FILE--
+countMatches('日本と日本、мир niño'));
+foreach ($m->matchAll('x日本y') as $r) {
+ printf("%d:%d:%s\n", $r->offset, $r->length, $r->keyword);
+}
+$r = new Str\Replacer(['мир' => 'peace']);
+var_dump($r->replace('мир миру мир'));
+$m = new Str\Matcher(["a\0b", "\xff\xfe"]);
+var_dump($m->countMatches("x\xff\xfey a\0b z a\0b"));
+var_dump($m->matches("a\0b"));
+
+$matcher = new Str\Matcher(["\x97"]);
+$hit = $matcher->matchAll("\xe6\x97\xa5")[0];
+var_dump($hit->offset, $hit->length, bin2hex($hit->keyword));
+var_dump(bin2hex((new Str\Replacer(["\x97" => 'X']))->replace("\xe6\x97\xa5")));
+$keywords = [];
+for ($i = 0; $i < 256; $i++) {
+ $keywords[] = chr($i) . chr(255 - $i);
+}
+$matcher = new Str\Matcher($keywords);
+$subject = '';
+for ($i = 0; $i < 512; $i++) {
+ $subject .= chr(($i * 37 + 11) % 256);
+}
+$subject .= $keywords[0] . $keywords[128] . $keywords[255] . $keywords[7];
+$unique = [];
+$count = 0;
+foreach ($keywords as $keyword) {
+ if (str_contains($subject, $keyword)) {
+ $unique[] = $keyword;
+ }
+ $offset = 0;
+ while (($position = strpos($subject, $keyword, $offset)) !== false) {
+ $count++;
+ $offset = $position + 1;
+ }
+}
+sort($unique);
+$actual = $matcher->getMatchedKeywords($subject);
+sort($actual);
+var_dump($actual === $unique, $matcher->countMatches($subject) === $count);
+$map = array_fill_keys($keywords, 'R');
+var_dump((new Str\Replacer($map))->replace($subject) === strtr($subject, $map));
+?>
+--EXPECT--
+int(4)
+1:6:日本
+string(19) "peace peaceу peace"
+int(3)
+bool(true)
+int(1)
+int(1)
+string(2) "97"
+string(6) "e658a5"
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/strmatch/tests/007-reinit-safety.phpt b/ext/strmatch/tests/007-reinit-safety.phpt
new file mode 100644
index 000000000000..8098c7de9711
--- /dev/null
+++ b/ext/strmatch/tests/007-reinit-safety.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Str\Matcher and Str\Replacer reject repeated initialization, including empty dictionaries
+--EXTENSIONS--
+strmatch
+--FILE--
+ 'y']), new Str\Replacer([])] as $object) {
+ try {
+ $object->__construct([]);
+ } catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+$matcher = new Str\Matcher(['foo']);
+try {
+ $matcher->__construct(['bar']);
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+var_dump($matcher->matches('foo'), $matcher->matches('bar'));
+$replacer = new Str\Replacer(['x' => 'y']);
+try {
+ $replacer->__construct(['x' => 'z']);
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+echo $replacer->replace('x'), "\n";
+?>
+--EXPECT--
+Error: Str\Matcher is already initialized
+Error: Str\Matcher is already initialized
+Error: Str\Replacer is already initialized
+Error: Str\Replacer is already initialized
+Error: Str\Matcher is already initialized
+bool(true)
+bool(false)
+Error: Str\Replacer is already initialized
+y
diff --git a/ext/strmatch/tests/008-overlapping.phpt b/ext/strmatch/tests/008-overlapping.phpt
new file mode 100644
index 000000000000..85b8d7f2d277
--- /dev/null
+++ b/ext/strmatch/tests/008-overlapping.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Str\Matcher: overlapping keywords are all counted and listed
+--EXTENSIONS--
+strmatch
+--FILE--
+countMatches('aaaaa')); // 5x a + 4x aa + 3x aaa = 12
+foreach ($m->matchAll('aaa') as $r) {
+ printf("%d:%d:%s\n", $r->offset, $r->length, $r->keyword);
+}
+?>
+--EXPECT--
+int(12)
+0:1:a
+0:2:aa
+0:3:aaa
+1:1:a
+1:2:aa
+2:1:a
diff --git a/ext/strmatch/tests/011-differential.phpt b/ext/strmatch/tests/011-differential.phpt
new file mode 100644
index 000000000000..ab041234b738
--- /dev/null
+++ b/ext/strmatch/tests/011-differential.phpt
@@ -0,0 +1,93 @@
+--TEST--
+Str methods agree with independent occurrence and replacement oracles (350 seeded cases)
+--EXTENSIONS--
+strmatch
+--FILE--
+ [$a[0], $a[1]] <=> [$b[0], $b[1]]);
+ $unique = array_values(array_unique(array_column($hits, 2)));
+ $matcher = new Str\Matcher($keywords);
+ $actual = array_map(static fn ($hit) => [$hit->offset, $hit->length, $hit->keyword], $matcher->matchAll($subject));
+ if ($actual !== $hits
+ || $matcher->matches($subject) !== (bool) $hits
+ || $matcher->countMatches($subject) !== count($hits)
+ || $matcher->getMatchedKeywords($subject) !== $unique
+ || $matcher->getKeywords() !== $keywords) {
+ var_dump($map, $subject, $hits, $actual);
+ throw new Exception('Matcher oracle mismatch');
+ }
+
+ usort($hits, static fn ($a, $b) => ($a[0] <=> $b[0]) ?: ($b[1] <=> $a[1]));
+ $output = '';
+ $end = 0;
+ $count = 0;
+ foreach ($hits as [$offset, $length, $keyword]) {
+ if ($offset < $end) {
+ continue;
+ }
+ $output .= substr($subject, $end, $offset - $end) . $map[$keyword];
+ $end = $offset + $length;
+ $count++;
+ }
+ $output .= substr($subject, $end);
+ $replacer = new Str\Replacer($map);
+ $actual = $replacer->replace($subject, $actualCount);
+ if ($actual !== $output || $actualCount !== $count
+ || $actual !== strtr($subject, $map) || $replacer->getReplacements() !== $map) {
+ var_dump($map, $subject, $output, $actual, $count, $actualCount);
+ throw new Exception('Replacer oracle mismatch');
+ }
+}
+
+mt_srand(20261028);
+for ($case = 0; $case < 200; $case++) {
+ $map = [];
+ $size = mt_rand(1, 12);
+ for ($i = 0; $i < $size; $i++) {
+ $map[randomWord(6, 3)] = strtoupper(randomWord(6, 3));
+ }
+ $subject = '';
+ $length = mt_rand(0, 40);
+ for ($i = 0; $i < $length; $i++) {
+ $subject .= chr(mt_rand(97, 99));
+ }
+ checkCase($map, $subject);
+}
+
+mt_srand(424242);
+for ($case = 0; $case < 150; $case++) {
+ $keys = [];
+ $size = mt_rand(1, 10);
+ for ($i = 0; $i < $size; $i++) {
+ $keys[randomWord(5, 4)] = true;
+ }
+ $subject = '';
+ $length = mt_rand(0, 30);
+ for ($i = 0; $i < $length; $i++) {
+ $subject .= chr(mt_rand(97, 100));
+ }
+ checkCase(array_fill_keys(array_keys($keys), 'R'), $subject);
+}
+echo "350 cases OK\n";
+?>
+--EXPECT--
+350 cases OK
diff --git a/ext/strmatch/tests/015-api-contract.phpt b/ext/strmatch/tests/015-api-contract.phpt
new file mode 100644
index 000000000000..9f545e92354d
--- /dev/null
+++ b/ext/strmatch/tests/015-api-contract.phpt
@@ -0,0 +1,63 @@
+--TEST--
+Str API: final classes, immutable snapshots, private result construction and readonly fields
+--EXTENSIONS--
+strmatch
+--FILE--
+isFinal(), $reflection->getParentClass());
+}
+$value = 'before';
+$map = [-1 => 'negative', 123 => &$value, '01' => 'leading', "a\0b" => 'binary'];
+$r = new Str\Replacer($map);
+var_dump($r->getReplacements() === $map);
+$value = 'after';
+$snapshot = $r->getReplacements();
+var_dump($snapshot[123]);
+$snapshot[123] = 'changed';
+var_dump($r->replace('123'));
+var_dump(method_exists($r, 'matches'), method_exists($r, 'getKeywords'));
+try {
+ new Str\MatchResult();
+} catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+foreach ([new Str\Matcher([]), new Str\Replacer([])] as $object) {
+ try {
+ $object->extra = 1;
+ } catch (Error $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+
+$result = (new Str\Matcher(['ab', 'bc']))->matchAll('zabc')[0];
+printf("%d %d %s\n", $result->offset, $result->length, $result->keyword);
+foreach (['offset', 'length', 'keyword'] as $property) {
+ try {
+ $result->$property = $result->$property;
+ echo "FAIL\n";
+ } catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+}
+?>
+--EXPECT--
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+bool(true)
+string(6) "before"
+string(6) "before"
+bool(false)
+bool(false)
+Error: Call to private Str\MatchResult::__construct() from global scope
+Error: Cannot create dynamic property Str\Matcher::$extra
+Error: Cannot create dynamic property Str\Replacer::$extra
+1 2 ab
+Error: Cannot modify readonly property Str\MatchResult::$offset
+Error: Cannot modify readonly property Str\MatchResult::$length
+Error: Cannot modify readonly property Str\MatchResult::$keyword
diff --git a/ext/strmatch/tests/016-result-lifetime.phpt b/ext/strmatch/tests/016-result-lifetime.phpt
new file mode 100644
index 000000000000..97465b3f2bca
--- /dev/null
+++ b/ext/strmatch/tests/016-result-lifetime.phpt
@@ -0,0 +1,26 @@
+--TEST--
+Str\Matcher results retain keywords after inputs and matcher are destroyed
+--EXTENSIONS--
+strmatch
+--FILE--
+matchAll($keyword . $keyword);
+$keywords = $m->getKeywords();
+$matched = $m->getMatchedKeywords($keyword);
+$keyword = 'changed';
+unset($m);
+var_dump(count($results), $results[1]->length, strlen($results[1]->keyword));
+var_dump(strlen($keywords[0]), strlen($matched[1]));
+$keywords[0][0] = 'Y';
+var_dump($results[1]->keyword[0], $matched[1][0]);
+?>
+--EXPECT--
+int(193)
+int(64)
+int(64)
+int(64)
+int(64)
+string(1) "x"
+string(1) "x"
diff --git a/ext/strmatch/tests/017-count-overflow.phpt b/ext/strmatch/tests/017-count-overflow.phpt
new file mode 100644
index 000000000000..a5b32a858537
--- /dev/null
+++ b/ext/strmatch/tests/017-count-overflow.phpt
@@ -0,0 +1,21 @@
+--TEST--
+Str\Matcher counts beyond INT32_MAX and rejects PHP_INT_MAX overflow on 32-bit
+--EXTENSIONS--
+strmatch
+--FILE--
+countMatches(str_repeat('a', 8388736));
+ echo PHP_INT_SIZE === 8 && $count === 2147483776 ? "correct boundary behavior\n" : "FAIL\n";
+} catch (Error $e) {
+ echo PHP_INT_SIZE === 4 && $e->getMessage() === 'Match count exceeds PHP_INT_MAX'
+ ? "correct boundary behavior\n" : $e::class . ': ' . $e->getMessage() . "\n";
+}
+?>
+--EXPECT--
+correct boundary behavior
diff --git a/ext/strmatch/tests/018-replacer-oracle.phpt b/ext/strmatch/tests/018-replacer-oracle.phpt
new file mode 100644
index 000000000000..5113e9283aae
--- /dev/null
+++ b/ext/strmatch/tests/018-replacer-oracle.phpt
@@ -0,0 +1,67 @@
+--TEST--
+Str replacement: independent longest-match oracle, suffix priority, integer keys and aliasing
+--EXTENSIONS--
+strmatch
+--FILE--
+ $replacement) {
+ $keyword = (string) $keyword;
+ if (substr_compare($subject, $keyword, $position, strlen($keyword)) === 0
+ && ($best === null || strlen($keyword) > strlen($best))) {
+ $best = $keyword;
+ }
+ }
+ if ($best === null) {
+ $output .= $subject[$position++];
+ } else {
+ $output .= $map[$best];
+ $position += strlen($best);
+ $count++;
+ }
+ }
+ return [$output, $count];
+}
+$maps = [[], ['a' => '', 'aa' => 'a', 'aaa' => 'Z'], ['ab' => 'b', 'bc' => 'c', 'abc' => 'Y'], ['x' => 'X', 'xy' => 'Z', 'y' => 'x'], ["a\0" => '', "\0b" => 'a', "\xff" => "\0"]];
+$subjects = ['', 'q', 'a', 'aaaaaaa', 'abcabc', 'xxyxy', "a\0b\xffa\0"];
+$cases = 0;
+foreach ($maps as $map) {
+ $replacer = new Str\Replacer($map);
+ foreach ($subjects as $subject) {
+ $expected = oracle($subject, $map);
+ $actual = [$replacer->replace($subject, $count), $count];
+ if ($actual !== $expected || $actual[0] !== strtr($subject, $map)) {
+ var_dump($map, $subject, $expected, $actual);
+ exit;
+ }
+ $cases++;
+ }
+}
+echo $cases, " cases OK\n";
+$subject = str_repeat('x', 64);
+var_dump((new Str\Replacer([]))->replace($subject, $subject) === str_repeat('x', 64), $subject);
+echo (new Str\Replacer(['cab' => 'LONG', 'a' => 'SHORT']))->replace('ab', $count), "\n";
+var_dump($count);
+echo (new Str\Replacer(['ab' => 'X', 'bcde' => 'Y']))->replace('abcde', $count), "\n";
+var_dump($count);
+
+$map = [-1 => 'X', 123 => 'Y', 'ab' => 'Z'];
+$replacer = new Str\Replacer($map);
+var_dump($replacer->replace('q-1 123 ab', $count), $count);
+var_dump($replacer->getReplacements() === $map);
+?>
+--EXPECT--
+35 cases OK
+bool(true)
+int(0)
+SHORTb
+int(1)
+Xcde
+int(1)
+string(6) "qX Y Z"
+int(3)
+bool(true)
diff --git a/ext/strmatch/tests/020-count-output.phpt b/ext/strmatch/tests/020-count-output.phpt
new file mode 100644
index 000000000000..de705785a976
--- /dev/null
+++ b/ext/strmatch/tests/020-count-output.phpt
@@ -0,0 +1,45 @@
+--TEST--
+Str\Replacer output count: aliasing and typed references with matches, no matches and empty inputs
+--EXTENSIONS--
+strmatch
+--FILE--
+ 'Y']);
+foreach ([null, 'before', [], false] as $count) {
+ var_dump($r->replace('xx', $count), $count);
+}
+$subject = str_repeat('x', 64);
+var_dump($r->replace($subject, $subject) === str_repeat('Y', 64), $subject);
+
+$box = new class { public string $count = 'before'; };
+foreach ([[$r, 'xx'], [$r, 'absent'], [$r, ''], [new Str\Replacer([]), 'xx']] as [$replacer, $subject]) {
+ try {
+ $replacer->replace($subject, $box->count);
+ echo "FAIL\n";
+ } catch (TypeError $e) {
+ echo $e::class, "\n";
+ }
+ var_dump($box->count);
+}
+?>
+--EXPECT--
+string(2) "YY"
+int(2)
+string(2) "YY"
+int(2)
+string(2) "YY"
+int(2)
+string(2) "YY"
+int(2)
+bool(true)
+int(64)
+TypeError
+string(6) "before"
+TypeError
+string(6) "before"
+TypeError
+string(6) "before"
+TypeError
+string(6) "before"
diff --git a/ext/strmatch/tests/021-object-comparison.phpt b/ext/strmatch/tests/021-object-comparison.phpt
new file mode 100644
index 000000000000..23f7a6ba2509
--- /dev/null
+++ b/ext/strmatch/tests/021-object-comparison.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Str\Matcher and Str\Replacer compare by object identity
+--EXTENSIONS--
+strmatch
+--FILE--
+ 'A'];
+ $otherDictionary = $class === Str\Matcher::class ? ['b'] : ['a' => 'B'];
+ $first = new $class($dictionary);
+ $sameDictionary = new $class($dictionary);
+ $differentDictionary = new $class($otherDictionary);
+ var_dump($first == $first);
+ var_dump($first == $sameDictionary);
+ var_dump($first == $differentDictionary);
+ var_dump(in_array($sameDictionary, [$first]));
+ var_dump(in_array($first, [$first]));
+ var_dump(array_search($differentDictionary, [$first]));
+ var_dump(array_search($first, [$first]));
+}
+$first = (new Str\Matcher(['a']))->matchAll('a')[0];
+$second = (new Str\Matcher(['a']))->matchAll('a')[0];
+var_dump($first == $second);
+?>
+--EXPECT--
+bool(true)
+bool(false)
+bool(false)
+bool(false)
+bool(true)
+bool(false)
+int(0)
+bool(true)
+bool(false)
+bool(false)
+bool(false)
+bool(true)
+bool(false)
+int(0)
+bool(true)
diff --git a/ext/strmatch/tests/022-duplicate-memory.phpt b/ext/strmatch/tests/022-duplicate-memory.phpt
new file mode 100644
index 000000000000..dce4f541c343
--- /dev/null
+++ b/ext/strmatch/tests/022-duplicate-memory.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Str\Matcher deduplication retains the same storage as a single keyword
+--EXTENSIONS--
+strmatch
+--SKIPIF--
+
+--FILE--
+
+--EXPECT--
+bool(true)
diff --git a/ext/strmatch/tests/023-hybrid-build.phpt b/ext/strmatch/tests/023-hybrid-build.phpt
new file mode 100644
index 000000000000..7663aef1cd78
--- /dev/null
+++ b/ext/strmatch/tests/023-hybrid-build.phpt
@@ -0,0 +1,64 @@
+--TEST--
+Str hybrid construction: periodic failure paths, partial mismatches and branching boundaries
+--EXTENSIONS--
+strmatch
+--FILE--
+ [$a[0], $a[1]] <=> [$b[0], $b[1]]);
+ $matcher = new Str\Matcher($keywords);
+ $actual = array_map(static fn ($hit) => [$hit->offset, $hit->length, $hit->keyword], $matcher->matchAll($subject));
+ if ($actual !== $expected
+ || $matcher->countMatches($subject) !== count($expected)
+ || $matcher->matches($subject) !== (bool) $expected
+ || $matcher->getMatchedKeywords($subject) !== array_values(array_unique(array_column($expected, 2)))) {
+ var_dump($subject, $expected, $actual);
+ throw new Exception('Matcher fallback mismatch');
+ }
+}
+
+function checkReplacer(array $map, string $subject, string $expected, int $expectedCount): void {
+ $replacer = new Str\Replacer($map);
+ $actual = $replacer->replace($subject, $count);
+ if ($actual !== $expected || $actual !== strtr($subject, $map) || $count !== $expectedCount) {
+ var_dump($subject, $expected, $actual, $count);
+ throw new Exception('Replacer fallback mismatch');
+ }
+}
+
+foreach (['abcd', 'abcde', 'abcdef', "\0\x80ab\xff"] as $period) {
+ $word = str_repeat($period, 128);
+ $prefix = substr($word, 0, -1);
+ foreach (['!x' . $word . '?', 'x' . $prefix . 'x' . $word, 'x' . $prefix, 'unrelated'] as $subject) {
+ checkMatcher([$word, 'x' . $word], $subject);
+ }
+ $map = [$word => 'W', $word . 'x' => 'WX'];
+ checkReplacer($map, '!' . $word . 'x?', '!WX?', 1);
+ checkReplacer($map, $word . 'x' . substr($word, 1) . 'x', 'WX' . substr($word, 1) . 'x', 1);
+ checkReplacer($map, substr($word, 1) . 'x', substr($word, 1) . 'x', 0);
+}
+foreach ([7, 8] as $degree) {
+ $map = [];
+ for ($i = 0; $i < $degree; $i++) {
+ $map['pqrstuABCDE' . chr(128 + $i) . 'tail'] = 'R' . $i;
+ }
+ $words = array_keys($map);
+ $subject = '!' . $words[$degree - 1] . '|' . $words[0] . '?';
+ checkMatcher($words, $subject);
+ checkReplacer($map, $subject, '!R' . ($degree - 1) . '|R0?', 2);
+ $subject = substr($words[0], 0, -1) . $words[$degree - 1];
+ checkMatcher($words, $subject);
+ checkReplacer($map, $subject, substr($words[0], 0, -1) . 'R' . ($degree - 1), 1);
+}
+echo "OK\n";
+?>
+--EXPECT--
+OK
diff --git a/ext/strmatch/tests/024-object-lifecycle.phpt b/ext/strmatch/tests/024-object-lifecycle.phpt
new file mode 100644
index 000000000000..59ac6d653199
--- /dev/null
+++ b/ext/strmatch/tests/024-object-lifecycle.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Str prepared objects reject cloning, serialization and construction bypasses
+--EXTENSIONS--
+strmatch
+--FILE--
+ 'Y'])] as $object) {
+ $class = $object::class;
+ foreach ([
+ static fn () => clone $object,
+ static fn () => serialize($object),
+ static fn () => unserialize(sprintf('O:%d:"%s":0:{}', strlen($class), $class)),
+ static fn () => (new ReflectionClass($class))->newInstanceWithoutConstructor(),
+ static fn () => (new ReflectionClass($class))->newLazyGhost(static function ($object) {}),
+ ] as $operation) {
+ try {
+ $operation();
+ echo "FAIL\n";
+ } catch (Throwable $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+ }
+ }
+}
+?>
+--EXPECT--
+Error: Trying to clone an uncloneable object of class Str\Matcher
+Exception: Serialization of 'Str\Matcher' is not allowed
+Exception: Unserialization of 'Str\Matcher' is not allowed
+ReflectionException: Class Str\Matcher is an internal class marked as final that cannot be instantiated without invoking its constructor
+Error: Cannot make instance of internal class lazy: Str\Matcher is internal
+Error: Trying to clone an uncloneable object of class Str\Replacer
+Exception: Serialization of 'Str\Replacer' is not allowed
+Exception: Unserialization of 'Str\Replacer' is not allowed
+ReflectionException: Class Str\Replacer is an internal class marked as final that cannot be instantiated without invoking its constructor
+Error: Cannot make instance of internal class lazy: Str\Replacer is internal