From a3f22b7c91e32108cad351772c353653dd4d8507 Mon Sep 17 00:00:00 2001 From: Artem Ukrainskiy Date: Wed, 11 Nov 2026 03:45:31 +0300 Subject: [PATCH] ext/standard: Add prepared string matching and replacement Add final Str\Matcher and Str\Replacer classes that prepare a fixed dictionary of literal keys once and reuse it across subjects. Str\Matcher reports whether any key occurs, the matched keys, the number of occurrences and every occurrence with its byte offset. Str\Replacer applies leftmost-longest replacement with strtr() array semantics. Matcher keys must be strings. Both build a hybrid Aho-Corasick automaton: dense rows near the root, packed sparse transitions deeper, row offsets stored as state IDs. Replacement scans a reversed automaton. Objects compare equal when they hold the same key set or mapping, and are not cloneable or serializable; neither is MatchResult. Include 15 PHPT tests for API semantics, binary input, overlapping matches, the replacement oracle and object lifecycle. --- ...flectionExtension_getClassNames_basic.phpt | 3 + ext/standard/basic_functions.c | 1 + ext/standard/basic_functions.h | 1 + ext/standard/config.m4 | 1 + ext/standard/config.w32 | 2 +- ext/standard/strmatch.c | 1334 +++++++++++++++++ ext/standard/strmatch.stub.php | 54 + ext/standard/strmatch_arginfo.h | 117 ++ ext/standard/tests/strmatch/001-matcher.phpt | 71 + ext/standard/tests/strmatch/002-replacer.phpt | 11 + ext/standard/tests/strmatch/003-errors.phpt | 23 + ext/standard/tests/strmatch/004-binary.phpt | 61 + .../tests/strmatch/007-reinit-safety.phpt | 36 + .../tests/strmatch/008-overlapping.phpt | 18 + .../tests/strmatch/011-differential.phpt | 89 ++ .../tests/strmatch/015-api-contract.phpt | 61 + .../tests/strmatch/016-result-lifetime.phpt | 24 + .../tests/strmatch/017-count-overflow.phpt | 19 + .../tests/strmatch/018-replacer-oracle.phpt | 69 + .../tests/strmatch/021-object-comparison.phpt | 113 ++ .../tests/strmatch/022-duplicate-memory.phpt | 23 + .../tests/strmatch/023-hybrid-build.phpt | 62 + .../tests/strmatch/024-object-lifecycle.phpt | 33 + 23 files changed, 2225 insertions(+), 1 deletion(-) create mode 100644 ext/standard/strmatch.c create mode 100644 ext/standard/strmatch.stub.php create mode 100644 ext/standard/strmatch_arginfo.h create mode 100644 ext/standard/tests/strmatch/001-matcher.phpt create mode 100644 ext/standard/tests/strmatch/002-replacer.phpt create mode 100644 ext/standard/tests/strmatch/003-errors.phpt create mode 100644 ext/standard/tests/strmatch/004-binary.phpt create mode 100644 ext/standard/tests/strmatch/007-reinit-safety.phpt create mode 100644 ext/standard/tests/strmatch/008-overlapping.phpt create mode 100644 ext/standard/tests/strmatch/011-differential.phpt create mode 100644 ext/standard/tests/strmatch/015-api-contract.phpt create mode 100644 ext/standard/tests/strmatch/016-result-lifetime.phpt create mode 100644 ext/standard/tests/strmatch/017-count-overflow.phpt create mode 100644 ext/standard/tests/strmatch/018-replacer-oracle.phpt create mode 100644 ext/standard/tests/strmatch/021-object-comparison.phpt create mode 100644 ext/standard/tests/strmatch/022-duplicate-memory.phpt create mode 100644 ext/standard/tests/strmatch/023-hybrid-build.phpt create mode 100644 ext/standard/tests/strmatch/024-object-lifecycle.phpt 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