*/ 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 */ 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 $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 * } */ 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 * } */ 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 $match * @return array{ * span: array{0: int, 1: int}, * captures: list * } */ 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 * }>, * 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 */ 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 * }> $records * @param list $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 */ function regex_tools_identity(): array { $test = []; $matched = preg_match( '~(?é+)~u', '😀éé', $test, PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL, ); $replaced = preg_replace('~(?é+)~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 */ 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."}'; }