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