diff --git a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
index c497855f7347..b8b31d587bba 100644
--- a/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
+++ b/ext/reflection/tests/ReflectionExtension_getClassNames_basic.phpt
@@ -32,6 +32,9 @@ Io\Poll\PollException
Io\Poll\Watcher
RoundingMode
SortDirection
+Str\MatchResult
+Str\Matcher
+Str\Replacer
StreamBucket
StreamError
StreamErrorCode
diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c
index 9a86030ba019..bacc6054ccc4 100644
--- a/ext/standard/basic_functions.c
+++ b/ext/standard/basic_functions.c
@@ -304,6 +304,7 @@ PHP_MINIT_FUNCTION(basic) /* {{{ */
BASIC_MINIT_SUBMODULE(standard_filters)
BASIC_MINIT_SUBMODULE(user_filters)
BASIC_MINIT_SUBMODULE(poll)
+ BASIC_MINIT_SUBMODULE(strmatch)
BASIC_MINIT_SUBMODULE(password)
BASIC_MINIT_SUBMODULE(image)
diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h
index 10dfd584d704..efcc66bc9525 100644
--- a/ext/standard/basic_functions.h
+++ b/ext/standard/basic_functions.h
@@ -43,6 +43,7 @@ PHP_MINFO_FUNCTION(basic);
ZEND_API void php_get_highlight_struct(zend_syntax_highlighter_ini *syntax_highlighter_ini);
PHP_MINIT_FUNCTION(poll);
+PHP_MINIT_FUNCTION(strmatch);
PHP_MINIT_FUNCTION(user_filters);
PHP_RSHUTDOWN_FUNCTION(user_filters);
PHP_RSHUTDOWN_FUNCTION(browscap);
diff --git a/ext/standard/config.m4 b/ext/standard/config.m4
index 7edfcf1da1f5..395ae10d45cc 100644
--- a/ext/standard/config.m4
+++ b/ext/standard/config.m4
@@ -440,6 +440,7 @@ PHP_NEW_EXTENSION([standard], m4_normalize([
soundex.c
streamsfuncs.c
string.c
+ strmatch.c
strnatcmp.c
syslog.c
type.c
diff --git a/ext/standard/config.w32 b/ext/standard/config.w32
index 56096097620d..4720c7268080 100644
--- a/ext/standard/config.w32
+++ b/ext/standard/config.w32
@@ -30,7 +30,7 @@ EXTENSION("standard", "array.c base64.c basic_functions.c browscap.c \
file.c filestat.c formatted_print.c fsock.c head.c html.c image.c \
info.c io_poll.c iptc.c link.c mail.c math.c md5.c metaphone.c microtime.c \
net.c pack.c pageinfo.c quot_print.c soundex.c \
- string.c scanf.c syslog.c type.c uniqid.c url.c var.c \
+ string.c strmatch.c scanf.c syslog.c type.c uniqid.c url.c var.c \
versioning.c assert.c strnatcmp.c levenshtein.c incomplete_class.c \
url_scanner_ex.c ftp_fopen_wrapper.c http_fopen_wrapper.c \
php_fopen_wrapper.c credits.c css.c var_unserializer.c ftok.c sha1.c \
diff --git a/ext/standard/strmatch.c b/ext/standard/strmatch.c
new file mode 100644
index 000000000000..71ea4620ffbc
--- /dev/null
+++ b/ext/standard/strmatch.c
@@ -0,0 +1,1334 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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 "basic_functions.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_PRESIZE_LIMIT 4096
+#define AC_DENSE_DEPTH 3
+#define AC_DENSE_MIN_CHILDREN 8
+#define AC_DENSE_FAILURE_WINDOW 4
+#define AC_INITIAL_CANDIDATE_LIMIT 256
+#define AC_SAMPLE_BYTES 256
+#define AC_RUNS_SWITCH_PERCENT 12
+
+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;
+ uint32_t dense_limit;
+ uint32_t stride;
+ ac_build_state *build_states;
+ int *child_pool;
+ uint32_t child_pool_used;
+ uint32_t child_pool_size;
+ 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;
+
+typedef struct {
+ size_t offset;
+ int keyword;
+} ac_candidate;
+
+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 an output target as a state ID. */
+static zend_always_inline uint32_t ac_transition(const ac_automaton *automaton, uint32_t state, unsigned char byte,
+ const bool runs)
+{
+ unsigned int column = automaton->alphabet[byte];
+
+ if (runs && column == 0) {
+ return 0;
+ }
+
+ if (EXPECTED(state < automaton->dense_limit)) {
+ return automaton->transitions[(size_t) state + column];
+ }
+ if (column == 0) {
+ return 0;
+ }
+ for (;;) {
+ if (state < automaton->dense_limit) {
+ return automaton->transitions[(size_t) state + column];
+ }
+ const ac_sparse_state *meta = &automaton->sparse[state - automaton->dense_limit];
+ 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 = (uint32_t) meta->fail;
+ }
+}
+
+/* True when sampled bytes rarely switch between key-alphabet bytes and other bytes. */
+static bool ac_prefer_runs(const ac_automaton *automaton, const zend_string *subject)
+{
+ size_t length = MIN(ZSTR_LEN(subject), AC_SAMPLE_BYTES);
+ const unsigned char *bytes = (const unsigned char *) ZSTR_VAL(subject);
+ size_t switches = 0;
+
+ for (size_t i = 1; i < length; i++) {
+ switches += (automaton->alphabet[bytes[i]] == 0) != (automaton->alphabet[bytes[i - 1]] == 0);
+ }
+ return switches * 100 < length * AC_RUNS_SWITCH_PERCENT;
+}
+
+static zend_always_inline size_t ac_next_match_impl(const ac_automaton *automaton, const unsigned char *bytes,
+ size_t i, size_t length, uint32_t *state, const bool runs)
+{
+ uint32_t current = *state;
+
+ for (; i < length; i++) {
+ current = ac_transition(automaton, current, bytes[i], runs);
+ if (UNEXPECTED(current & AC_MATCH_FLAG)) {
+ break;
+ }
+ }
+ *state = current;
+ return i;
+}
+
+/* Scans bytes[i, length) and returns the offset of the first byte that ends a match, or length. */
+static zend_always_inline size_t ac_next_match(const ac_automaton *automaton, const unsigned char *bytes,
+ size_t i, size_t length, uint32_t *state, bool runs)
+{
+ return runs ? ac_next_match_impl(automaton, bytes, i, length, state, true)
+ : ac_next_match_impl(automaton, bytes, i, length, state, false);
+}
+
+static zend_always_inline size_t ac_previous_match_impl(const ac_automaton *automaton, const unsigned char *bytes,
+ size_t i, uint32_t *state, const bool runs)
+{
+ uint32_t current = *state;
+
+ while (i > 0) {
+ i--;
+ current = ac_transition(automaton, current, bytes[i], runs);
+ if (UNEXPECTED(current & AC_MATCH_FLAG)) {
+ *state = current;
+ return i;
+ }
+ }
+ *state = current;
+ return SIZE_MAX;
+}
+
+/* Scans bytes[0, i) backwards and returns the offset of the first byte that ends a match, or SIZE_MAX. */
+static zend_always_inline size_t ac_previous_match(const ac_automaton *automaton, const unsigned char *bytes,
+ size_t i, uint32_t *state, bool runs)
+{
+ return runs ? ac_previous_match_impl(automaton, bytes, i, state, true)
+ : ac_previous_match_impl(automaton, bytes, i, state, false);
+}
+
+/* Converts an encoded state to the index of its output metadata. */
+static zend_always_inline uint32_t ac_state_index(const ac_automaton *automaton, uint32_t state)
+{
+ return state < automaton->dense_limit ? state / automaton->stride
+ : state - automaton->dense_limit + automaton->dense_count;
+}
+
+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)
+{
+ const ac_build_state *meta = &automaton->build_states[state];
+
+ /* During the build, a nonzero column is the 1-based row of a direct child index. */
+ if (meta->column) {
+ return automaton->child_pool[(size_t) (meta->column - 1) * automaton->alphabet_size + column];
+ }
+ for (uint32_t edge = meta->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);
+}
+
+/* Build-only direct child index for one state: one pool slot per column, -1 for no child. */
+static void ac_index_children(ac_automaton *automaton, const ac_build_edge *edges, int state)
+{
+ uint32_t width = (uint32_t) automaton->alphabet_size;
+
+ if (automaton->child_pool_used / width >= UINT16_MAX) {
+ return;
+ }
+ if (automaton->child_pool_size - automaton->child_pool_used < width) {
+ automaton->child_pool_size = MAX(automaton->child_pool_size * 2, automaton->child_pool_used + width);
+ automaton->child_pool = safe_erealloc(automaton->child_pool, automaton->child_pool_size, sizeof(int), 0);
+ }
+ uint32_t row = automaton->child_pool_used;
+
+ automaton->child_pool_used += width;
+ for (uint32_t column = 0; column < width; column++) {
+ automaton->child_pool[row + column] = -1;
+ }
+ for (uint32_t e = automaton->build_states[state].link; e != UINT32_MAX; e = edges[e].next) {
+ automaton->child_pool[row + edges[e].column] = edges[e].target;
+ }
+ automaton->build_states[state].column = (uint16_t) (row / width + 1);
+}
+
+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;
+
+ /* Every key byte adds at most one state and one edge; larger builds grow by doubling. */
+ size_t total = 0;
+
+ for (uint32_t k = 0; k < automaton->keyword_count; k++) {
+ total += ZSTR_LEN(automaton->keywords[k]);
+ }
+ automaton->capacity = (int) MIN(total + 1, (size_t) AC_PRESIZE_LIMIT);
+ automaton->build_states = safe_emalloc(automaton->capacity, sizeof(ac_build_state), 0);
+ if (ac_add_build_state(automaton) < 0) {
+ return FAILURE;
+ }
+ if (reversed) {
+ automaton->build_states[0].output.longest = -1;
+ }
+ uint32_t used = 0, capacity = (uint32_t) MIN(total, (size_t) AC_PRESIZE_LIMIT);
+ ac_build_edge *build_edges = safe_emalloc(capacity, sizeof(ac_build_edge), 0);
+
+ ac_index_children(automaton, build_edges, 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++;
+ uint16_t row = automaton->build_states[state].column;
+
+ if (row) {
+ automaton->child_pool[(size_t) (row - 1) * automaton->alphabet_size + column] = target;
+ } else if (automaton->build_states[state].child_count == AC_DENSE_MIN_CHILDREN) {
+ ac_index_children(automaton, build_edges, state);
+ }
+ }
+ 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;
+
+ /* 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;
+ }
+ }
+ ZEND_ASSERT(dense_states == dense_count - 1);
+
+ /* Children first; UINT32_MAX marks a transition resolved through failure links below. */
+ for (int position = -1; position < dense_states; position++) {
+ int old = position < 0 ? 0 : queue[position];
+ uint32_t *row = automaton->transitions + (size_t) state_map[old] * automaton->alphabet_size;
+
+ row[0] = 0;
+ for (int column = 1; column < automaton->alphabet_size; column++) {
+ row[column] = old == 0 ? 0 : UINT32_MAX;
+ }
+ for (uint32_t e = automaton->build_states[old].link; e != UINT32_MAX; e = build_edges[e].next) {
+ row[build_edges[e].column] = (uint32_t) state_map[build_edges[e].target];
+ }
+ }
+ /* A row is complete when its failure row is complete; failure rows precede their states in BFS order. */
+ bool *complete = ecalloc(dense_count, sizeof(bool));
+ int pending = 0;
+
+ complete[0] = true;
+ for (int position = 0; position < dense_states; position++) {
+ int old = queue[position];
+ int fail = state_map[automaton->build_states[old].fail];
+
+ if (fail >= dense_count || !complete[fail]) {
+ queue[pending++] = old;
+ continue;
+ }
+ uint32_t *row = automaton->transitions + (size_t) state_map[old] * automaton->alphabet_size;
+ const uint32_t *fail_row = automaton->transitions + (size_t) fail * automaton->alphabet_size;
+
+ for (int column = 1; column < automaton->alphabet_size; column++) {
+ row[column] = row[column] == UINT32_MAX ? fail_row[column] : row[column];
+ }
+ complete[state_map[old]] = true;
+ }
+ efree(complete);
+
+ /* Remaining rows: each column is completed in BFS order before the cache is reused. */
+ for (int column = 1; pending && column < automaton->alphabet_size; column++) {
+ for (int state = 0; state < state_count - dense_count; state++) {
+ cache[state] = -1;
+ }
+ for (int position = 0; position < pending; position++) {
+ int old = queue[position];
+ uint32_t *cell = automaton->transitions + (size_t) state_map[old] * automaton->alphabet_size + column;
+
+ if (*cell == UINT32_MAX) {
+ *cell = (uint32_t) ac_resolve_transition(automaton, build_edges, state_map,
+ dense_count, cache, automaton->build_states[old].fail, column);
+ }
+ }
+ }
+}
+
+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);
+}
+
+/* Copies build states to their mapped positions: dense states first, then sparse states. */
+static void ac_pack_states(ac_automaton *automaton, const int *state_map, 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 old = 0; old < automaton->state_count; old++) {
+ const ac_build_state *meta = &automaton->build_states[old];
+ int state = state_map[old];
+
+ 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 zend_always_inline uint32_t ac_encode_state(const ac_automaton *automaton, uint32_t state)
+{
+ return state < automaton->dense_count ? state * automaton->stride
+ : automaton->dense_limit + (state - automaton->dense_count);
+}
+
+/* Stored targets become dense row offsets, or dense_limit + sparse index, with AC_MATCH_FLAG on
+ * targets that can report a match. */
+static zend_result ac_encode_targets(ac_automaton *automaton, size_t edge_count, bool reversed, uint32_t *encoded)
+{
+ uint32_t state_count = (uint32_t) automaton->state_count;
+ uint32_t sparse_count = state_count - automaton->dense_count;
+
+ if (UNEXPECTED((uint64_t) automaton->dense_count * (uint32_t) automaton->alphabet_size + sparse_count
+ > AC_STATE_MASK)) {
+ zend_throw_error(NULL, "Dictionary requires too many matching states");
+ return FAILURE;
+ }
+ automaton->stride = (uint32_t) automaton->alphabet_size;
+ automaton->dense_limit = automaton->dense_count * automaton->stride;
+
+ for (uint32_t state = 0; state < state_count; state++) {
+ bool output = reversed ? automaton->longest[state] >= 0 : automaton->states[state].output_count != 0;
+
+ encoded[state] = ac_encode_state(automaton, state) | (output ? AC_MATCH_FLAG : 0);
+ }
+ for (size_t i = 0; i < (size_t) automaton->dense_limit; i++) {
+ automaton->transitions[i] = encoded[automaton->transitions[i]];
+ }
+ for (size_t i = 0; i < edge_count; i++) {
+ automaton->edges[i].target = encoded[automaton->edges[i].target];
+ }
+ for (uint32_t i = 0; i < sparse_count; i++) {
+ ac_sparse_state *meta = &automaton->sparse[i];
+
+ if (meta->count == 1) {
+ meta->link = encoded[meta->link];
+ }
+ meta->fail = (int) (encoded[meta->fail] & AC_STATE_MASK);
+ }
+ return SUCCESS;
+}
+
+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);
+
+ efree(automaton->child_pool);
+ automaton->child_pool = NULL;
+ ac_pack_edges(automaton, build_edges, state_map, dense_count, automaton->edges, edge_count);
+ efree(build_edges);
+ ac_pack_states(automaton, state_map, dense_count, reversed);
+
+ /* The state map is no longer needed; its slots hold the encoded targets. */
+ zend_result result = ac_encode_targets(automaton, edge_count, reversed, (uint32_t *) state_map);
+
+ efree(state_map);
+ return result;
+}
+
+static void ac_destroy(ac_automaton *automaton)
+{
+ if (automaton->child_pool) {
+ efree(automaton->child_pool);
+ }
+ 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 int matcher_compare(zval *left, zval *right)
+{
+ ZEND_COMPARE_OBJECTS_FALLBACK(left, right);
+
+ if (Z_OBJ_P(left) == Z_OBJ_P(right)) {
+ return 0;
+ }
+ if (Z_OBJCE_P(left) != Z_OBJCE_P(right)) {
+ return ZEND_UNCOMPARABLE;
+ }
+
+ const ac_automaton *a = &matcher_from_object(Z_OBJ_P(left))->automaton;
+ const ac_automaton *b = &matcher_from_object(Z_OBJ_P(right))->automaton;
+ if (!a->initialized || !b->initialized || a->keyword_count != b->keyword_count) {
+ return ZEND_UNCOMPARABLE;
+ }
+
+ uint32_t i;
+ for (i = 0; i < a->keyword_count; i++) {
+ if (!zend_string_equals(a->keywords[i], b->keywords[i])) {
+ break;
+ }
+ if (a->replacements && !zend_string_equals(a->replacements[i], b->replacements[i])) {
+ return ZEND_UNCOMPARABLE;
+ }
+ }
+ if (i == a->keyword_count) {
+ return 0;
+ }
+
+ /* Keys are unique, so equal counts and containment mean the same key set or mapping. */
+ HashTable mapping;
+ zend_hash_init(&mapping, b->keyword_count, NULL, NULL, false);
+ for (i = 0; i < b->keyword_count; i++) {
+ zend_hash_add_new_ptr(&mapping, b->keywords[i], b->replacements ? b->replacements[i] : b->keywords[i]);
+ }
+ bool equal = true;
+ for (i = 0; i < a->keyword_count; i++) {
+ zend_string *value = zend_hash_find_ptr(&mapping, a->keywords[i]);
+ if (!value || (a->replacements && !zend_string_equals(a->replacements[i], value))) {
+ equal = false;
+ break;
+ }
+ }
+ zend_hash_destroy(&mapping);
+ return equal ? 0 : ZEND_UNCOMPARABLE;
+}
+
+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;
+
+ RETURN_BOOL(ac_next_match(automaton, (const unsigned char *) ZSTR_VAL(subject), 0, ZSTR_LEN(subject), &state,
+ ac_prefer_runs(automaton, subject)) < ZSTR_LEN(subject));
+}
+
+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;
+ const unsigned char *bytes = (const unsigned char *) ZSTR_VAL(subject);
+ size_t length = ZSTR_LEN(subject);
+ bool runs = ac_prefer_runs(automaton, subject);
+
+ for (size_t i = ac_next_match(automaton, bytes, 0, length, &state, runs); i < length;
+ i = ac_next_match(automaton, bytes, i + 1, length, &state, runs)) {
+ state &= AC_STATE_MASK;
+ if (UNEXPECTED(!ac_count_add(&count, automaton->states[ac_state_index(automaton, 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;
+ const unsigned char *bytes = (const unsigned char *) ZSTR_VAL(subject);
+ size_t length = ZSTR_LEN(subject);
+ bool runs = ac_prefer_runs(automaton, subject);
+
+ for (size_t i = ac_next_match(automaton, bytes, 0, length, &state, runs); i < length;
+ i = ac_next_match(automaton, bytes, i + 1, length, &state, runs)) {
+ state &= AC_STATE_MASK;
+ uint32_t index = ac_state_index(automaton, state);
+ int output = automaton->states[index].keyword >= 0 ? (int) index : automaton->states[index].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;
+ }
+ if (count == automaton->keyword_count) {
+ break;
+ }
+ }
+ 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;
+ const unsigned char *bytes = (const unsigned char *) ZSTR_VAL(subject);
+ size_t length = ZSTR_LEN(subject);
+ bool runs = ac_prefer_runs(automaton, subject);
+
+ for (size_t i = ac_next_match(automaton, bytes, 0, length, &state, runs); i < length;
+ i = ac_next_match(automaton, bytes, i + 1, length, &state, runs)) {
+ state &= AC_STATE_MASK;
+ uint32_t index = ac_state_index(automaton, state);
+ int output = automaton->states[index].keyword >= 0 ? (int) index : automaton->states[index].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;
+
+ 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;
+ int *best = NULL;
+ ac_candidate *matches = NULL;
+ size_t match_count = 0, capacity = 0, limit = 0, candidates = 0;
+
+ if (automaton->keyword_count) {
+ uint32_t state = 0;
+ size_t i = ZSTR_LEN(subject);
+ const unsigned char *bytes = (const unsigned char *) ZSTR_VAL(subject);
+ bool runs = ac_prefer_runs(automaton, subject);
+
+ for (;;) {
+ i = ac_previous_match(automaton, bytes, i, &state, runs);
+ if (i == SIZE_MAX) {
+ i = 0;
+ break;
+ }
+ state &= AC_STATE_MASK;
+ if (candidates == 0) {
+ candidates = i + 1;
+ limit = MIN(AC_INITIAL_CANDIDATE_LIMIT, candidates / sizeof(ac_candidate) * sizeof(int));
+ }
+ if (match_count == capacity) {
+ if (capacity == limit && capacity >= AC_INITIAL_CANDIDATE_LIMIT) {
+ size_t sparse_limit = candidates / (4 * sizeof(ac_candidate));
+
+ if (capacity < sparse_limit
+ && match_count <= (candidates - i) / (4 * sizeof(ac_candidate))) {
+ limit = MIN(sparse_limit, capacity * 2);
+ }
+ }
+ if (capacity == limit) {
+ best = safe_emalloc(candidates, sizeof(int), 0);
+ for (size_t j = i + 1; j < candidates; j++) {
+ best[j] = -1;
+ }
+ for (size_t j = 0; j < match_count; j++) {
+ best[matches[j].offset] = matches[j].keyword;
+ }
+ if (matches) {
+ efree(matches);
+ matches = NULL;
+ }
+ best[i] = automaton->longest[ac_state_index(automaton, state)];
+ break;
+ }
+ capacity = MIN(limit, capacity ? capacity * 2 : 16);
+ matches = safe_erealloc(matches, capacity, sizeof(ac_candidate), 0);
+ }
+ matches[match_count].offset = i;
+ matches[match_count++].keyword = automaton->longest[ac_state_index(automaton, state)];
+ }
+ while (i > 0) {
+ size_t match = ac_previous_match(automaton, bytes, i, &state, runs);
+ size_t first = match == SIZE_MAX ? 0 : match + 1;
+
+ memset(best + first, 0xff, (i - first) * sizeof(int));
+ if (match == SIZE_MAX) {
+ break;
+ }
+ state &= AC_STATE_MASK;
+ best[match] = automaton->longest[ac_state_index(automaton, state)];
+ i = match;
+ }
+ }
+ if (candidates == 0) {
+ RETURN_STR_COPY(subject);
+ }
+
+ smart_str output = {0};
+ size_t position = 0, literal = 0;
+
+ while (best ? position < candidates : match_count > 0) {
+ int keyword;
+
+ if (best) {
+ keyword = best[position];
+ if (keyword < 0) {
+ position++;
+ continue;
+ }
+ } else {
+ const ac_candidate *match = &matches[--match_count];
+
+ if (match->offset < position) {
+ continue;
+ }
+ position = match->offset;
+ keyword = match->keyword;
+ }
+ 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;
+ }
+ if (ZSTR_LEN(subject) > literal) {
+ smart_str_appendl(&output, ZSTR_VAL(subject) + literal, ZSTR_LEN(subject) - literal);
+ }
+ if (best) {
+ efree(best);
+ } else {
+ efree(matches);
+ }
+ smart_str_0(&output);
+ RETURN_STR(output.s ? output.s : ZSTR_EMPTY_ALLOC());
+}
+
+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 = matcher_compare;
+
+ 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;
+}
diff --git a/ext/standard/strmatch.stub.php b/ext/standard/strmatch.stub.php
new file mode 100644
index 000000000000..cb2f2d64e603
--- /dev/null
+++ b/ext/standard/strmatch.stub.php
@@ -0,0 +1,54 @@
+ */
+ public function getMatchedKeywords(string $subject): array {}
+
+ /** @return list<\Str\MatchResult> */
+ public function matchAll(string $subject): array {}
+
+ /** @return list */
+ public function getKeywords(): array {}
+}
+
+/**
+ * @strict-properties
+ * @not-serializable
+ */
+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) {}
+
+ public function replace(string $subject): string {}
+
+ /** @return array */
+ public function getReplacements(): array {}
+}
diff --git a/ext/standard/strmatch_arginfo.h b/ext/standard/strmatch_arginfo.h
new file mode 100644
index 000000000000..cd0839cd8aa1
--- /dev/null
+++ b/ext/standard/strmatch_arginfo.h
@@ -0,0 +1,117 @@
+/* This is a generated file, edit strmatch.stub.php instead.
+ * Stub hash: 795b5d0fe7762811660454bb533dcefb8425cd74 */
+
+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_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|ZEND_ACC_NOT_SERIALIZABLE);
+
+ 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/standard/tests/strmatch/001-matcher.phpt b/ext/standard/tests/strmatch/001-matcher.phpt
new file mode 100644
index 000000000000..0023f12e78f0
--- /dev/null
+++ b/ext/standard/tests/strmatch/001-matcher.phpt
@@ -0,0 +1,71 @@
+--TEST--
+Str\Matcher: methods, start order, empty inputs and dictionary deduplication
+--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/standard/tests/strmatch/002-replacer.phpt b/ext/standard/tests/strmatch/002-replacer.phpt
new file mode 100644
index 000000000000..a7ac251250e4
--- /dev/null
+++ b/ext/standard/tests/strmatch/002-replacer.phpt
@@ -0,0 +1,11 @@
+--TEST--
+Str\Replacer: replace with strtr semantics (longest-match, non-overlapping)
+--FILE--
+ 'dog', 'cats' => 'DOGS', 'a' => 'A']);
+var_dump($r->replace('a cat and cats'));
+var_dump($r->replace('nothing here'));
+?>
+--EXPECT--
+string(14) "A dog And DOGS"
+string(12) "nothing here"
diff --git a/ext/standard/tests/strmatch/003-errors.phpt b/ext/standard/tests/strmatch/003-errors.phpt
new file mode 100644
index 000000000000..546ab7846a60
--- /dev/null
+++ b/ext/standard/tests/strmatch/003-errors.phpt
@@ -0,0 +1,23 @@
+--TEST--
+Str constructors reject empty keywords and non-string dictionary values
+--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/standard/tests/strmatch/004-binary.phpt b/ext/standard/tests/strmatch/004-binary.phpt
new file mode 100644
index 000000000000..a6ab94fce25b
--- /dev/null
+++ b/ext/standard/tests/strmatch/004-binary.phpt
@@ -0,0 +1,61 @@
+--TEST--
+Str matching is byte-based: UTF-8, embedded NUL, high bytes and the full alphabet
+--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/standard/tests/strmatch/007-reinit-safety.phpt b/ext/standard/tests/strmatch/007-reinit-safety.phpt
new file mode 100644
index 000000000000..24ef46e5de7e
--- /dev/null
+++ b/ext/standard/tests/strmatch/007-reinit-safety.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Str\Matcher and Str\Replacer reject repeated initialization, including empty dictionaries
+--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/standard/tests/strmatch/008-overlapping.phpt b/ext/standard/tests/strmatch/008-overlapping.phpt
new file mode 100644
index 000000000000..f33ab3fa658c
--- /dev/null
+++ b/ext/standard/tests/strmatch/008-overlapping.phpt
@@ -0,0 +1,18 @@
+--TEST--
+Str\Matcher: overlapping keywords are all counted and listed
+--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/standard/tests/strmatch/011-differential.phpt b/ext/standard/tests/strmatch/011-differential.phpt
new file mode 100644
index 000000000000..d3b8ce04bf69
--- /dev/null
+++ b/ext/standard/tests/strmatch/011-differential.phpt
@@ -0,0 +1,89 @@
+--TEST--
+Str methods agree with independent occurrence and replacement oracles (350 seeded cases)
+--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;
+ foreach ($hits as [$offset, $length, $keyword]) {
+ if ($offset < $end) {
+ continue;
+ }
+ $output .= substr($subject, $end, $offset - $end) . $map[$keyword];
+ $end = $offset + $length;
+ }
+ $output .= substr($subject, $end);
+ $replacer = new Str\Replacer($map);
+ $actual = $replacer->replace($subject);
+ if ($actual !== $output
+ || $actual !== strtr($subject, $map) || $replacer->getReplacements() !== $map) {
+ var_dump($map, $subject, $output, $actual);
+ 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/standard/tests/strmatch/015-api-contract.phpt b/ext/standard/tests/strmatch/015-api-contract.phpt
new file mode 100644
index 000000000000..e1c1d17a77bd
--- /dev/null
+++ b/ext/standard/tests/strmatch/015-api-contract.phpt
@@ -0,0 +1,61 @@
+--TEST--
+Str API: final classes, immutable snapshots, private result construction and readonly fields
+--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/standard/tests/strmatch/016-result-lifetime.phpt b/ext/standard/tests/strmatch/016-result-lifetime.phpt
new file mode 100644
index 000000000000..21caafd35453
--- /dev/null
+++ b/ext/standard/tests/strmatch/016-result-lifetime.phpt
@@ -0,0 +1,24 @@
+--TEST--
+Str\Matcher results retain keywords after inputs and matcher are destroyed
+--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/standard/tests/strmatch/017-count-overflow.phpt b/ext/standard/tests/strmatch/017-count-overflow.phpt
new file mode 100644
index 000000000000..e9dbfe03391a
--- /dev/null
+++ b/ext/standard/tests/strmatch/017-count-overflow.phpt
@@ -0,0 +1,19 @@
+--TEST--
+Str\Matcher counts beyond INT32_MAX and rejects PHP_INT_MAX overflow on 32-bit
+--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/standard/tests/strmatch/018-replacer-oracle.phpt b/ext/standard/tests/strmatch/018-replacer-oracle.phpt
new file mode 100644
index 000000000000..e789a030fc57
--- /dev/null
+++ b/ext/standard/tests/strmatch/018-replacer-oracle.phpt
@@ -0,0 +1,69 @@
+--TEST--
+Str replacement: independent longest-match oracle, suffix priority and integer keys
+--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);
+ }
+ }
+ return $output;
+}
+$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);
+ if ($actual !== $expected || $actual !== 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);
+echo (new Str\Replacer(['cab' => 'LONG', 'a' => 'SHORT']))->replace('ab'), "\n";
+echo (new Str\Replacer(['ab' => 'X', 'bcde' => 'Y']))->replace('abcde'), "\n";
+
+$map = [-1 => 'X', 123 => 'Y', 'ab' => 'Z'];
+$replacer = new Str\Replacer($map);
+var_dump($replacer->replace('q-1 123 ab'));
+var_dump($replacer->getReplacements() === $map);
+
+$map = ['a' => 'A', 'aa' => 'Q', 'aba' => 'X'];
+$chunk = str_repeat('x', 253) . 'aba';
+$subjects = [str_repeat($chunk, 300), str_repeat('a', 2000) . str_repeat($chunk, 300)];
+foreach ($subjects as $subject) {
+ $replacer = new Str\Replacer($map);
+ if ($replacer->replace($subject) !== oracle($subject, $map)) {
+ throw new Exception('Long-subject replacement mismatch');
+ }
+}
+echo "Long subjects OK\n";
+?>
+--EXPECT--
+35 cases OK
+bool(true)
+SHORTb
+Xcde
+string(6) "qX Y Z"
+bool(true)
+Long subjects OK
diff --git a/ext/standard/tests/strmatch/021-object-comparison.phpt b/ext/standard/tests/strmatch/021-object-comparison.phpt
new file mode 100644
index 000000000000..7f9291fc44cf
--- /dev/null
+++ b/ext/standard/tests/strmatch/021-object-comparison.phpt
@@ -0,0 +1,113 @@
+--TEST--
+Str\Matcher and Str\Replacer compare their normalized dictionaries
+--FILE--
+ 'a', 7 => 'b', 'a']), new Str\Matcher(['a', 'b']), true);
+compare('Matcher ignores order', new Str\Matcher(['a', 'b']), new Str\Matcher(['b', 'a']), true);
+compare('Matcher different key', new Str\Matcher(['a']), new Str\Matcher(['b']), false);
+compare('Matcher different size', new Str\Matcher(['a']), new Str\Matcher(['a', 'b']), false);
+compare('Matcher numeric strings', new Str\Matcher(['01']), new Str\Matcher(['1']), false);
+compare('Matcher binary', new Str\Matcher(["a\0b"]), new Str\Matcher(["a\0b"]), true);
+compare('Matcher binary difference', new Str\Matcher(["a\0b"]), new Str\Matcher(["a\0c"]), false);
+
+compare('Replacer same mapping', new Str\Replacer(['a' => 'A', 'b' => 'B']), new Str\Replacer(['a' => 'A', 'b' => 'B']), true);
+compare('Replacer ignores order', new Str\Replacer(['a' => 'A', 'b' => 'B']), new Str\Replacer(['b' => 'B', 'a' => 'A']), true);
+compare('Replacer reordered different value', new Str\Replacer(['a' => 'A', 'b' => 'B']), new Str\Replacer(['b' => 'B', 'a' => 'C']), false);
+compare('Replacer different value', new Str\Replacer(['a' => 'A']), new Str\Replacer(['a' => 'B']), false);
+compare('Replacer different key', new Str\Replacer(['a' => 'A']), new Str\Replacer(['b' => 'A']), false);
+compare('Replacer different size', new Str\Replacer(['a' => 'A']), new Str\Replacer(['a' => 'A', 'b' => 'B']), false);
+compare('Replacer numeric keys', new Str\Replacer([12 => 'A', 'b' => 'B']), new Str\Replacer(['b' => 'B', '12' => 'A']), true);
+compare('Replacer distinct numeric keys', new Str\Replacer(['01' => 'A']), new Str\Replacer(['1' => 'A']), false);
+compare('Replacer numeric values', new Str\Replacer(['a' => '01']), new Str\Replacer(['a' => '1']), false);
+compare('Replacer empty values', new Str\Replacer(['a' => '', 'b' => 'B']), new Str\Replacer(['b' => 'B', 'a' => '']), true);
+compare('Replacer reordered numeric values', new Str\Replacer(['a' => '01', 'b' => 'B']), new Str\Replacer(['b' => 'B', 'a' => '1']), false);
+compare('Replacer binary', new Str\Replacer(["a\0" => "A\0", 'b' => 'B']), new Str\Replacer(['b' => 'B', "a\0" => "A\0"]), true);
+compare('Replacer binary value difference', new Str\Replacer(["a\0" => "A\0", 'b' => 'B']), new Str\Replacer(['b' => 'B', "a\0" => "A\1"]), false);
+compare('Replacer binary key difference', new Str\Replacer(["a\0" => 'A', 'b' => 'B']), new Str\Replacer(['b' => 'B', "a\1" => 'A']), false);
+
+foreach ([Str\Matcher::class, Str\Replacer::class] as $class) {
+ compare($class . ' empty', new $class([]), new $class([]), true);
+ $dictionary = $class === Str\Matcher::class ? ['a'] : ['a' => 'A'];
+ $first = new $class($dictionary);
+ $second = new $class($dictionary);
+ compare($class . ' self', $first, $first, true);
+ var_dump($first === $first, $first === $second);
+ var_dump(in_array($second, [$first]), in_array($second, [$first], true));
+ var_dump(array_search($second, [$first]), array_search($second, [$first], true));
+ compare($class . ' other class', $first, new stdClass(), false);
+}
+compare('Different prepared classes', new Str\Matcher(['a']), new Str\Replacer(['a' => 'a']), false);
+compare('Different empty classes', new Str\Matcher([]), new Str\Replacer([]), false);
+
+$keyword = 'a';
+$matcher = new Str\Matcher([&$keyword]);
+$keyword = 'b';
+$matcher->matchAll('aaaa');
+compare('Matcher snapshot after use', $matcher, new Str\Matcher(['a']), true);
+$value = 'A';
+$replacer = new Str\Replacer(['a' => &$value, 'b' => 'B']);
+$value = 'C';
+$replacer->replace('aba');
+compare('Replacer snapshot after use', $replacer, new Str\Replacer(['b' => 'B', 'a' => 'A']), true);
+
+$first = (new Str\Matcher(['a']))->matchAll('a')[0];
+$second = (new Str\Matcher(['a']))->matchAll('a')[0];
+compare('MatchResult', $first, $second, true);
+?>
+--EXPECT--
+Matcher same dictionary: equal
+Matcher ignores input keys and duplicates: equal
+Matcher ignores order: equal
+Matcher different key: different
+Matcher different size: different
+Matcher numeric strings: different
+Matcher binary: equal
+Matcher binary difference: different
+Replacer same mapping: equal
+Replacer ignores order: equal
+Replacer reordered different value: different
+Replacer different value: different
+Replacer different key: different
+Replacer different size: different
+Replacer numeric keys: equal
+Replacer distinct numeric keys: different
+Replacer numeric values: different
+Replacer empty values: equal
+Replacer reordered numeric values: different
+Replacer binary: equal
+Replacer binary value difference: different
+Replacer binary key difference: different
+Str\Matcher empty: equal
+Str\Matcher self: equal
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+int(0)
+bool(false)
+Str\Matcher other class: different
+Str\Replacer empty: equal
+Str\Replacer self: equal
+bool(true)
+bool(false)
+bool(true)
+bool(false)
+int(0)
+bool(false)
+Str\Replacer other class: different
+Different prepared classes: different
+Different empty classes: different
+Matcher snapshot after use: equal
+Replacer snapshot after use: equal
+MatchResult: equal
diff --git a/ext/standard/tests/strmatch/022-duplicate-memory.phpt b/ext/standard/tests/strmatch/022-duplicate-memory.phpt
new file mode 100644
index 000000000000..3f4bfff7fd80
--- /dev/null
+++ b/ext/standard/tests/strmatch/022-duplicate-memory.phpt
@@ -0,0 +1,23 @@
+--TEST--
+Str\Matcher deduplication retains the same storage as a single keyword
+--SKIPIF--
+
+--FILE--
+
+--EXPECT--
+bool(true)
diff --git a/ext/standard/tests/strmatch/023-hybrid-build.phpt b/ext/standard/tests/strmatch/023-hybrid-build.phpt
new file mode 100644
index 000000000000..d13a339b50c0
--- /dev/null
+++ b/ext/standard/tests/strmatch/023-hybrid-build.phpt
@@ -0,0 +1,62 @@
+--TEST--
+Str hybrid construction: periodic failure paths, partial mismatches and branching boundaries
+--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): void {
+ $replacer = new Str\Replacer($map);
+ $actual = $replacer->replace($subject);
+ if ($actual !== $expected || $actual !== strtr($subject, $map)) {
+ var_dump($subject, $expected, $actual);
+ 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?');
+ checkReplacer($map, $word . 'x' . substr($word, 1) . 'x', 'WX' . substr($word, 1) . 'x');
+ checkReplacer($map, substr($word, 1) . 'x', substr($word, 1) . 'x');
+}
+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?');
+ $subject = substr($words[0], 0, -1) . $words[$degree - 1];
+ checkMatcher($words, $subject);
+ checkReplacer($map, $subject, substr($words[0], 0, -1) . 'R' . ($degree - 1));
+}
+echo "OK\n";
+?>
+--EXPECT--
+OK
diff --git a/ext/standard/tests/strmatch/024-object-lifecycle.phpt b/ext/standard/tests/strmatch/024-object-lifecycle.phpt
new file mode 100644
index 000000000000..c250b04152cc
--- /dev/null
+++ b/ext/standard/tests/strmatch/024-object-lifecycle.phpt
@@ -0,0 +1,33 @@
+--TEST--
+Str prepared objects reject cloning, serialization and construction bypasses
+--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