Files
regex-tools/engines/php/bridge.php

1083 lines
31 KiB
PHP

<?php
declare(strict_types=1);
/*
* Fixed PHP bridge executed from the runtime's private virtual filesystem.
*
* The browser writes user-controlled values to request.json as JSON. This
* script never evaluates PHP source assembled from those values. The only
* generated string is a preg pattern made from the raw expression, a safe
* delimiter and validated preg modifiers.
*/
const REGEX_TOOLS_ABI = 1;
const REGEX_TOOLS_REQUEST = '/regex-tools/request.json';
const REGEX_TOOLS_MAX_PATTERN_UTF16 = 65_536;
const REGEX_TOOLS_MAX_SUBJECT_BYTES = 16_777_216;
const REGEX_TOOLS_MAX_REPLACEMENT_UTF16 = 65_536;
const REGEX_TOOLS_MAX_OUTPUT_BYTES = 67_108_864;
const REGEX_TOOLS_MAX_MATCHES = 10_000;
const REGEX_TOOLS_MAX_CAPTURE_ROWS = 100_000;
const REGEX_TOOLS_MAX_CAPTURE_GROUPS = 1_000;
const REGEX_TOOLS_MAX_ERROR_CHARACTERS = 1_024;
const REGEX_TOOLS_FLAGS = 'gimsxuUADJnr';
final class RegexToolsFailure extends RuntimeException
{
public function __construct(
public readonly string $phase,
public readonly string $failureCode,
string $message,
public readonly ?int $patternOffset = null,
) {
parent::__construct($message);
}
}
/**
* @return array<string, mixed>
*/
function regex_tools_failure(Throwable $error): array
{
$phase = $error instanceof RegexToolsFailure ? $error->phase : 'bridge';
$code = $error instanceof RegexToolsFailure
? $error->failureCode
: 'bridge-error';
$message = $error->getMessage();
if (preg_match('//u', $message) !== 1) {
$message = 'The PHP runtime reported a non-UTF-8 error.';
}
if (strlen($message) > REGEX_TOOLS_MAX_ERROR_CHARACTERS) {
$message = substr($message, 0, REGEX_TOOLS_MAX_ERROR_CHARACTERS - 3);
while ($message !== '' && preg_match('//u', $message) !== 1) {
$message = substr($message, 0, -1);
}
$message .= '…';
}
$result = [
'abi' => REGEX_TOOLS_ABI,
'ok' => false,
'phase' => $phase,
'code' => $code,
'message' => $message,
];
if ($error instanceof RegexToolsFailure && $error->patternOffset !== null) {
$result['position'] = $error->patternOffset;
}
return $result;
}
function regex_tools_preg_warning(
int $severity,
string $message,
string $file,
int $line,
): never {
unset($file, $line);
$offset = null;
if (preg_match('/\\bat offset (\\d+)\\z/D', $message, $offsetMatch) === 1) {
$offset = (int) $offsetMatch[1];
}
throw new RegexToolsFailure(
'compile',
'compile-error',
preg_replace('/^preg_[a-z_]+\\(\\):\\s*/', '', $message) ?? $message,
$offset,
);
}
set_error_handler(
static function (
int $severity,
string $message,
string $file,
int $line,
): never {
regex_tools_preg_warning($severity, $message, $file, $line);
},
);
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('html_errors', '0');
/**
* @param mixed $condition
*/
function regex_tools_require(
mixed $condition,
string $message,
string $code = 'invalid-request',
): void {
if (!$condition) {
throw new RegexToolsFailure('bridge', $code, $message);
}
}
function regex_tools_within_utf16_limit(
string $value,
int $maximumUnits,
): bool {
$units = 0;
$length = strlen($value);
for ($index = 0; $index < $length;) {
$leadingByte = ord($value[$index]);
if ($leadingByte < 0x80) {
$index++;
$units++;
} elseif ($leadingByte < 0xE0) {
$index += 2;
$units++;
} elseif ($leadingByte < 0xF0) {
$index += 3;
$units++;
} else {
$index += 4;
$units += 2;
}
if ($units > $maximumUnits) {
return false;
}
}
return true;
}
/**
* @return array<string, mixed>
*/
function regex_tools_request(): array
{
$encoded = file_get_contents(REGEX_TOOLS_REQUEST);
regex_tools_require(
is_string($encoded),
'The PHP bridge request file could not be read.',
);
try {
$request = json_decode(
$encoded,
true,
64,
JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING,
);
} catch (JsonException $error) {
throw new RegexToolsFailure(
'bridge',
'invalid-json',
'The PHP bridge request is not valid JSON: ' . $error->getMessage(),
);
}
regex_tools_require(
is_array($request) && !array_is_list($request),
'The PHP bridge request must be a JSON object.',
);
regex_tools_require(
($request['abi'] ?? null) === REGEX_TOOLS_ABI,
'The PHP bridge ABI does not match.',
'abi-mismatch',
);
return $request;
}
/**
* @param array<string, mixed> $request
*/
function regex_tools_validate_request(array $request): void
{
$operation = $request['operation'] ?? null;
regex_tools_require(
$operation === 'execute' || $operation === 'replace',
'The PHP bridge operation is unsupported.',
'invalid-operation',
);
foreach (['pattern', 'flags', 'subject'] as $field) {
regex_tools_require(
isset($request[$field]) && is_string($request[$field]),
"The PHP bridge field {$field} must be text.",
);
}
regex_tools_require(
preg_match('//u', $request['pattern']) === 1
&& preg_match('//u', $request['subject']) === 1,
'PHP preg inputs must be valid UTF-8.',
'invalid-utf8',
);
regex_tools_require(
regex_tools_within_utf16_limit(
$request['pattern'],
REGEX_TOOLS_MAX_PATTERN_UTF16,
),
'The PHP pattern exceeds the UTF-16 limit.',
'pattern-limit',
);
$flags = $request['flags'];
regex_tools_require(
strspn($flags, REGEX_TOOLS_FLAGS) === strlen($flags)
&& count(array_unique(str_split($flags))) === strlen($flags),
'The PHP bridge flags are invalid.',
'invalid-flags',
);
$options = $request['options'] ?? [];
regex_tools_require(
is_array($options) && count($options) === 0,
'The PHP preg engine does not accept options.',
'invalid-options',
);
regex_tools_require(
isset($request['maximumMatches'])
&& is_int($request['maximumMatches'])
&& $request['maximumMatches'] >= 1
&& $request['maximumMatches'] <= REGEX_TOOLS_MAX_MATCHES,
'The PHP match limit is outside the bounded contract.',
);
regex_tools_require(
isset($request['maximumCaptureRows'])
&& is_int($request['maximumCaptureRows'])
&& $request['maximumCaptureRows'] >= 1
&& $request['maximumCaptureRows']
<= REGEX_TOOLS_MAX_CAPTURE_ROWS,
'The PHP capture-row limit is outside the bounded contract.',
);
regex_tools_require(
strlen($request['subject']) <= REGEX_TOOLS_MAX_SUBJECT_BYTES,
'The PHP subject exceeds the UTF-8 byte limit.',
);
if ($operation === 'replace') {
regex_tools_require(
isset($request['replacement'])
&& is_string($request['replacement'])
&& preg_match('//u', $request['replacement']) === 1,
'The PHP replacement must be valid UTF-8 text.',
);
regex_tools_require(
regex_tools_within_utf16_limit(
$request['replacement'],
REGEX_TOOLS_MAX_REPLACEMENT_UTF16,
),
'The PHP replacement exceeds the UTF-16 limit.',
'replacement-limit',
);
regex_tools_require(
isset($request['maximumOutputBytes'])
&& is_int($request['maximumOutputBytes'])
&& $request['maximumOutputBytes'] >= 1
&& $request['maximumOutputBytes']
<= REGEX_TOOLS_MAX_OUTPUT_BYTES,
'The PHP output limit is outside the bounded contract.',
);
}
}
function regex_tools_escape_delimiter(string $pattern, string $delimiter): string
{
$escaped = '';
$backslashes = 0;
$length = strlen($pattern);
for ($index = 0; $index < $length; $index++) {
$character = $pattern[$index];
if ($character === $delimiter) {
if ($backslashes % 2 === 0) {
$escaped .= '\\';
}
$escaped .= $character;
$backslashes = 0;
} else {
$escaped .= $character;
$backslashes = $character === '\\' ? $backslashes + 1 : 0;
}
}
return $escaped;
}
function regex_tools_pattern(string $raw, string $flags): string
{
$candidates = ['~', '#', '%', '!', '@', ';', ':', ',', '=', '`', '/'];
$delimiter = '~';
$body = null;
foreach ($candidates as $candidate) {
if (!str_contains($raw, $candidate)) {
$delimiter = $candidate;
$body = $raw;
break;
}
}
if ($body === null) {
$body = regex_tools_escape_delimiter($raw, $delimiter);
}
$modifiers = str_replace('g', '', $flags);
return $delimiter . $body . $delimiter . $modifiers;
}
/**
* PHP exposes the compiled numeric capture table, but duplicate named groups
* share one associative-array key. This conservative scanner recovers every
* duplicate declaration only when its capture count agrees with PHP's
* authoritative count; otherwise the native table below remains authoritative.
*
* @return array{
* groupCount: int,
* groupNames: list<array{0: int, 1: string}>
* }
*/
function regex_tools_lexical_capture_names(
string $raw,
string $flags,
): array {
$groupCount = 0;
$groupNames = [];
$inClass = false;
$inQuote = false;
$extended = str_contains($flags, 'x');
$noAutoCapture = str_contains($flags, 'n');
$length = strlen($raw);
for ($index = 0; $index < $length; $index++) {
$character = $raw[$index];
if ($inQuote) {
if (
$character === '\\'
&& ($raw[$index + 1] ?? '') === 'E'
) {
$inQuote = false;
$index++;
}
continue;
}
if ($character === '\\') {
if (($raw[$index + 1] ?? '') === 'Q') {
$inQuote = true;
}
$index++;
continue;
}
if ($inClass) {
if ($character === ']') {
$inClass = false;
}
continue;
}
if ($character === '[') {
$inClass = true;
continue;
}
if ($extended && $character === '#') {
while (
$index + 1 < $length
&& $raw[$index + 1] !== "\n"
&& $raw[$index + 1] !== "\r"
) {
$index++;
}
continue;
}
if ($character !== '(') {
continue;
}
if (($raw[$index + 1] ?? '') !== '?') {
if (!$noAutoCapture) {
$groupCount++;
}
continue;
}
$marker = $raw[$index + 2] ?? '';
$nameStart = null;
$nameEndMarker = null;
if (
$marker === '<'
&& ($raw[$index + 3] ?? '') !== '='
&& ($raw[$index + 3] ?? '') !== '!'
) {
$nameStart = $index + 3;
$nameEndMarker = '>';
} elseif ($marker === "'") {
$nameStart = $index + 3;
$nameEndMarker = "'";
} elseif (
$marker === 'P'
&& ($raw[$index + 3] ?? '') === '<'
) {
$nameStart = $index + 4;
$nameEndMarker = '>';
} elseif ($marker === '#') {
while (
$index + 1 < $length
&& $raw[$index + 1] !== ')'
) {
$index++;
}
continue;
}
if ($nameStart === null || $nameEndMarker === null) {
continue;
}
$nameEnd = strpos($raw, $nameEndMarker, $nameStart);
if ($nameEnd === false) {
continue;
}
$name = substr($raw, $nameStart, $nameEnd - $nameStart);
$groupCount++;
if ($name !== '') {
$groupNames[] = [$groupCount, $name];
}
$index = $nameEnd;
}
return [
'groupCount' => $groupCount,
'groupNames' => $groupNames,
];
}
/**
* @return array{
* groupCount: int,
* groupNames: list<array{0: int, 1: string}>
* }
*/
function regex_tools_capture_metadata(string $raw, string $flags): array
{
$pattern = regex_tools_pattern($raw, $flags);
try {
$compiled = preg_match($pattern, '');
} catch (RegexToolsFailure $error) {
throw $error;
} catch (Throwable $error) {
throw new RegexToolsFailure(
'compile',
'compile-error',
$error->getMessage(),
);
}
if ($compiled === false) {
throw new RegexToolsFailure(
'compile',
'compile-error',
preg_last_error_msg(),
);
}
/*
* Appending an empty top-level branch makes MatchData available even when
* the expression cannot match the empty string. It does not add a capture,
* so PHP's numeric keys remain the engine-authoritative group count.
*/
$probePattern = regex_tools_pattern($raw . '|(?:)', $flags);
$probe = [];
try {
$status = preg_match(
$probePattern,
'',
$probe,
PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL,
);
} catch (RegexToolsFailure $error) {
throw $error;
} catch (Throwable $error) {
throw new RegexToolsFailure(
'compile',
'compile-error',
$error->getMessage(),
);
}
if ($status !== 1) {
throw new RegexToolsFailure(
'compile',
'metadata-error',
'PHP preg could not inspect the compiled capture table.',
);
}
$groupCount = 0;
$groupNames = [];
$pendingNames = [];
foreach ($probe as $key => $_value) {
if (is_string($key)) {
$pendingNames[] = $key;
continue;
}
if ($key <= 0) {
continue;
}
$groupCount = max($groupCount, $key);
foreach ($pendingNames as $name) {
$groupNames[] = [$key, $name];
}
$pendingNames = [];
}
if ($groupCount > REGEX_TOOLS_MAX_CAPTURE_GROUPS) {
throw new RegexToolsFailure(
'compile',
'capture-group-limit',
"The pattern declares {$groupCount} capture groups; "
. 'the PHP bridge limit is '
. REGEX_TOOLS_MAX_CAPTURE_GROUPS
. '.',
);
}
$lexical = regex_tools_lexical_capture_names($raw, $flags);
if ($lexical['groupCount'] === $groupCount) {
$lexicalKeys = [];
foreach ($lexical['groupNames'] as [$number, $name]) {
$lexicalKeys[$number . "\0" . $name] = true;
}
$nativeNamesAreCovered = true;
foreach ($groupNames as [$number, $name]) {
if (!isset($lexicalKeys[$number . "\0" . $name])) {
$nativeNamesAreCovered = false;
break;
}
}
if ($nativeNamesAreCovered) {
$groupNames = $lexical['groupNames'];
}
}
return [
'groupCount' => $groupCount,
'groupNames' => $groupNames,
];
}
function regex_tools_is_utf8_boundary(string $subject, int $offset): bool
{
if ($offset === 0 || $offset === strlen($subject)) {
return true;
}
$byte = ord($subject[$offset]);
return $byte < 0x80 || $byte > 0xBF;
}
function regex_tools_next_utf8_offset(string $subject, int $offset): int
{
$length = strlen($subject);
if ($offset >= $length) {
return $length + 1;
}
$offset++;
while ($offset < $length) {
$byte = ord($subject[$offset]);
if ($byte < 0x80 || $byte > 0xBF) {
break;
}
$offset++;
}
return $offset;
}
/**
* @param array<int|string, array{0: ?string, 1: int}> $match
* @return array{
* span: array{0: int, 1: int},
* captures: list<null|array{0: int, 1: int}>
* }
*/
function regex_tools_match_record(
array $match,
int $groupCount,
string $subject,
): array {
$whole = $match[0] ?? null;
if (
!is_array($whole)
|| count($whole) !== 2
|| !is_string($whole[0])
|| !is_int($whole[1])
|| $whole[1] < 0
) {
throw new RegexToolsFailure(
'match',
'invalid-native-result',
'PHP preg returned a malformed whole-match record.',
);
}
$start = $whole[1];
$end = $start + strlen($whole[0]);
if (
!regex_tools_is_utf8_boundary($subject, $start)
|| !regex_tools_is_utf8_boundary($subject, $end)
) {
throw new RegexToolsFailure(
'match',
'unaligned-utf8-offset',
'PHP preg returned a byte offset inside a UTF-8 character. '
. 'Enable the u modifier for Unicode text.',
);
}
$captures = [];
for ($number = 1; $number <= $groupCount; $number++) {
$capture = $match[$number] ?? null;
if (
!is_array($capture)
|| count($capture) !== 2
|| (!is_string($capture[0]) && $capture[0] !== null)
|| !is_int($capture[1])
) {
throw new RegexToolsFailure(
'match',
'invalid-native-result',
'PHP preg returned a malformed capture record.',
);
}
if ($capture[0] === null || $capture[1] < 0) {
$captures[] = null;
continue;
}
$captureStart = $capture[1];
$captureEnd = $captureStart + strlen($capture[0]);
if (
!regex_tools_is_utf8_boundary($subject, $captureStart)
|| !regex_tools_is_utf8_boundary($subject, $captureEnd)
) {
throw new RegexToolsFailure(
'match',
'unaligned-utf8-offset',
'PHP preg returned a capture offset inside a UTF-8 character. '
. 'Enable the u modifier for Unicode text.',
);
}
$captures[] = [$captureStart, $captureEnd];
}
return [
'span' => [$start, $end],
'captures' => $captures,
];
}
/**
* @return array{
* matches: list<array{
* span: array{0: int, 1: int},
* captures: list<null|array{0: int, 1: int}>
* }>,
* resultsTruncated: bool
* }
*/
function regex_tools_collect(
string $pattern,
string $nonemptyPattern,
string $subject,
bool $global,
bool $unicode,
int $groupCount,
int $maximumMatches,
int $maximumCaptureRows,
): array {
$records = [];
$captureRows = 0;
$offset = 0;
$subjectBytes = strlen($subject);
$resultsTruncated = false;
$retryNonemptyAtStart = false;
while ($offset <= $subjectBytes) {
$match = [];
try {
$status = preg_match(
$retryNonemptyAtStart ? $nonemptyPattern : $pattern,
$subject,
$match,
PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL,
$offset,
);
} catch (RegexToolsFailure $error) {
throw new RegexToolsFailure(
'match',
'match-error',
$error->getMessage(),
$error->patternOffset,
);
} catch (Throwable $error) {
throw new RegexToolsFailure(
'match',
'match-error',
$error->getMessage(),
);
}
if ($status === false) {
throw new RegexToolsFailure(
'match',
'match-error',
preg_last_error_msg(),
);
}
if ($status === 0) {
if ($retryNonemptyAtStart) {
$retryNonemptyAtStart = false;
$offset = $unicode
? regex_tools_next_utf8_offset($subject, $offset)
: $offset + 1;
continue;
}
break;
}
$rows = max(1, $groupCount);
if (
count($records) >= $maximumMatches
|| $captureRows + $rows > $maximumCaptureRows
) {
$resultsTruncated = true;
break;
}
$record = regex_tools_match_record($match, $groupCount, $subject);
$records[] = $record;
$captureRows += $rows;
if (!$global) {
break;
}
[$start, $end] = $record['span'];
if ($end > $start) {
$offset = $end;
$retryNonemptyAtStart = false;
} elseif ($end >= $subjectBytes) {
break;
} else {
/*
* preg_match_all retries an empty match at the same position with
* PCRE2_NOTEMPTY_ATSTART before advancing. PHP does not expose that
* option on preg_match, so an anchored, non-empty derived pattern
* reproduces the retry while retaining the original captures.
*/
$offset = $end;
$retryNonemptyAtStart = true;
}
}
return [
'matches' => $records,
'resultsTruncated' => $resultsTruncated,
];
}
/**
* @return array{0: string, 1: int, 2: bool}
*/
function regex_tools_append_bounded(
string &$output,
string $fragment,
int $maximumBytes,
bool &$truncated,
): bool {
if ($fragment === '') {
return true;
}
if ($truncated) {
return false;
}
$remaining = $maximumBytes - strlen($output);
if (strlen($fragment) <= $remaining) {
$output .= $fragment;
return true;
}
$prefix = substr($fragment, 0, max(0, $remaining));
while ($prefix !== '' && preg_match('//u', $prefix) !== 1) {
$prefix = substr($prefix, 0, -1);
}
$output .= $prefix;
$truncated = true;
return false;
}
/**
* Mirrors preg_get_backref() from php-src 8.5.8. PHP replacement references
* consume one or two decimal digits and may use the ${n} spelling.
*
* @return null|array{0: int, 1: int}
*/
function regex_tools_parse_replacement_backref(
string $replacement,
int $index,
): ?array {
$length = strlen($replacement);
if ($index + 1 >= $length) {
return null;
}
$walk = $index;
$inBrace = false;
if (
$replacement[$walk] === '$'
&& ($replacement[$walk + 1] ?? '') === '{'
) {
$inBrace = true;
$walk++;
}
$walk++;
$first = $replacement[$walk] ?? '';
if (
$first === ''
|| ord($first) < ord('0')
|| ord($first) > ord('9')
) {
return null;
}
$backref = ord($first) - ord('0');
$walk++;
$second = $replacement[$walk] ?? '';
if (
$second !== ''
&& ord($second) >= ord('0')
&& ord($second) <= ord('9')
) {
$backref = ($backref * 10) + ord($second) - ord('0');
$walk++;
}
if ($inBrace) {
if (($replacement[$walk] ?? '') !== '}') {
return null;
}
$walk++;
}
return [$backref, $walk];
}
/**
* The parser is a direct, fixed translation of PHP 8.5.8's replacement loop
* and preg_get_backref(). Parsing once avoids repeatedly walking a large
* template for its literal spans.
*
* @return list<array{0: 'literal'|'backref', 1: string|int}>
*/
function regex_tools_replacement_tokens(string $replacement): array
{
$tokens = [];
$literal = '';
$walkLast = '';
$index = 0;
$length = strlen($replacement);
while ($index < $length) {
$character = $replacement[$index];
if ($character === '\\' || $character === '$') {
if ($walkLast === '\\') {
$literal[strlen($literal) - 1] = $character;
$index++;
$walkLast = '';
continue;
}
$parsed = regex_tools_parse_replacement_backref(
$replacement,
$index,
);
if ($parsed !== null) {
if ($literal !== '') {
$tokens[] = ['literal', $literal];
$literal = '';
}
[$backref, $index] = $parsed;
$tokens[] = ['backref', $backref];
continue;
}
}
$literal .= $character;
$index++;
$walkLast = $character;
}
if ($literal !== '') {
$tokens[] = ['literal', $literal];
}
return $tokens;
}
/**
* Applies replacement only to the authoritative records returned to the app.
* This is the cross-engine partial-result contract: once a match/capture cap
* is reached, the unprocessed tail remains unchanged. Every append is clipped
* before allocation can exceed the requested UTF-8 byte limit.
*
* @param list<array{
* span: array{0: int, 1: int},
* captures: list<null|array{0: int, 1: int}>
* }> $records
* @param list<array{0: 'literal'|'backref', 1: string|int}> $tokens
* @return array{0: string, 1: int, 2: bool}
*/
function regex_tools_replace_bounded(
string $subject,
array $records,
array $tokens,
int $maximumBytes,
): array {
$output = '';
$truncated = false;
$cursor = 0;
foreach ($records as $record) {
[$start, $end] = $record['span'];
if (!regex_tools_append_bounded(
$output,
substr($subject, $cursor, $start - $cursor),
$maximumBytes,
$truncated,
)) {
break;
}
foreach ($tokens as [$kind, $value]) {
if ($kind === 'literal') {
$fragment = $value;
} else {
$number = $value;
$range = $number === 0
? $record['span']
: ($record['captures'][$number - 1] ?? null);
$fragment = $range === null
? ''
: substr($subject, $range[0], $range[1] - $range[0]);
}
if (!regex_tools_append_bounded(
$output,
$fragment,
$maximumBytes,
$truncated,
)) {
break 2;
}
}
$cursor = $end;
}
if (!$truncated) {
regex_tools_append_bounded(
$output,
substr($subject, $cursor),
$maximumBytes,
$truncated,
);
}
return [$output, strlen($output), $truncated];
}
/**
* @return array<string, mixed>
*/
function regex_tools_identity(): array
{
$test = [];
$matched = preg_match(
'~(?<word>é+)~u',
'😀éé',
$test,
PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL,
);
$replaced = preg_replace('~(?<word>é+)~u', '[$1]', '😀éé');
$parityPattern = '~(a)(b)?~u';
$paritySubject = 'zab a';
$parityRecords = regex_tools_collect(
$parityPattern,
'~\\G(?:(a)(b)?)(?!\\G)~u',
$paritySubject,
true,
true,
2,
100,
100,
);
$parityReplacement = '$2:$1/${1}/\\1/\\\\1/\\$1/$99/${1x}';
[$bridgeReplacement, $_bridgeBytes, $bridgeTruncated] =
regex_tools_replace_bounded(
$paritySubject,
$parityRecords['matches'],
regex_tools_replacement_tokens($parityReplacement),
4_096,
);
$nativeReplacement = preg_replace(
$parityPattern,
$parityReplacement,
$paritySubject,
);
$pcreVersion = explode(' ', PCRE_VERSION, 2)[0];
$selfTest = $matched === 1
&& ($test[0][1] ?? null) === 4
&& strlen($test[0][0] ?? '') === 4
&& ($test['word'][1] ?? null) === 4
&& $replaced === '😀[éé]'
&& !$bridgeTruncated
&& $bridgeReplacement === $nativeReplacement;
return [
'abi' => REGEX_TOOLS_ABI,
'ok' => true,
'identity' => [
'bridge' => 'regex-tools-php-json',
'bridgeVersion' => REGEX_TOOLS_ABI,
'phpVersion' => PHP_VERSION,
'phpSapi' => PHP_SAPI,
'pcreVersion' => $pcreVersion,
'pcreVersionFull' => PCRE_VERSION,
'integerBytes' => PHP_INT_SIZE,
'offsetUnit' => 'utf8-byte',
'selfTest' => $selfTest,
],
];
}
/**
* @return array<string, mixed>
*/
function regex_tools_main(): array
{
$request = regex_tools_request();
if (($request['operation'] ?? null) === 'identity') {
return regex_tools_identity();
}
regex_tools_validate_request($request);
$rawPattern = $request['pattern'];
$flags = $request['flags'];
$subject = $request['subject'];
$metadata = regex_tools_capture_metadata($rawPattern, $flags);
$pattern = regex_tools_pattern($rawPattern, $flags);
$nonemptyPattern = regex_tools_pattern(
'\\G(?:' . $rawPattern . ')(?!\\G)',
$flags,
);
$collected = regex_tools_collect(
$pattern,
$nonemptyPattern,
$subject,
str_contains($flags, 'g'),
str_contains($flags, 'u'),
$metadata['groupCount'],
$request['maximumMatches'],
$request['maximumCaptureRows'],
);
$result = [
'abi' => REGEX_TOOLS_ABI,
'ok' => true,
'groupCount' => $metadata['groupCount'],
'groupNames' => $metadata['groupNames'],
'matches' => $collected['matches'],
'resultsTruncated' => $collected['resultsTruncated'],
];
if ($request['operation'] === 'execute') {
return $result;
}
[$bounded, $outputBytes, $outputTruncated] =
regex_tools_replace_bounded(
$subject,
$collected['matches'],
regex_tools_replacement_tokens($request['replacement']),
$request['maximumOutputBytes'],
);
/*
* Base64 keeps the JSON envelope within a predictable 4/3 expansion.
* Emitting arbitrary output directly as JSON could expand control bytes
* sixfold before the supervising worker has a chance to enforce its cap.
*/
$result['outputBase64'] = base64_encode($bounded);
$result['outputBytes'] = $outputBytes;
$result['outputTruncated'] = $outputTruncated;
return $result;
}
try {
$response = regex_tools_main();
} catch (Throwable $error) {
$response = regex_tools_failure($error);
}
try {
echo json_encode(
$response,
JSON_THROW_ON_ERROR
| JSON_UNESCAPED_SLASHES
| JSON_UNESCAPED_UNICODE,
);
} catch (Throwable $error) {
echo '{"abi":1,"ok":false,"phase":"bridge","code":"encoding-error",'
. '"message":"The PHP bridge response could not be encoded."}';
}