Search by

corvus-dotnet / corvus-regex-ecma262

mwadams

ECMA-262 (JavaScript) regular expressions as a PHP extension: the grammar and the answers of RegExp, every flag, UTF-8 and UTF-16, backed by Rust.

Package info

github.com/corvus-dotnet/corvus-regex-ecma262-php

Type:php-ext

Ext name:ext-corvus_regex_ecma262

pkg:composer/corvus-dotnet/corvus-regex-ecma262

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.5 2026-10-29 18:29 UTC

README

Regular expressions with the grammar and the answers of ECMA-262, the RegExp of JavaScript, for PHP: the corvus-regex-ecma262 Rust crate, as a PHP extension with builds for PHP 8.2 to 8.5 (NTS and ZTS) on Linux (glibc and musl, x86_64 and arm64), macOS (arm64) and Windows (x86_64), installed with PIE.

  • Every flag: d, g, i, m, s, u, v and y, with the grammar of each. Without u or v a pattern is read by the Annex B grammar and matched by UTF-16 code unit, as ECMA-262 matches it; with them, by code point. The v flag has nested classes, the difference and the intersection of classes, strings in a class and the properties of strings.
  • ECMAScript 2025: lookbehind, named groups (and a name shared between alternatives), modifier groups such as (?i:...), and every Unicode property ECMA-262 lists, from Unicode 17 tables the crate carries.
  • UTF-8 strings, with positions in bytes as PCRE's functions give them, and UTF-16 text (Utf16), with positions in code units as JavaScript gives them. A pattern answers as it does in a browser or Node, which PCRE does not.
  • A pattern without a backreference is matched in time proportional to its size times the text, and a pattern can be given a match timeout, as a Regex of .NET can, which every method honours and which caps the backtracking memory too, so with a timeout neither a pattern nor a text from outside can make a program hang.

Compliance. Over the ECMA-262 test data (what V8 says about its patterns and texts, test262's patterns and exec calls, the global search, replace and split among them, and texts of bytes that are not UTF-8, which it refuses), through this extension over Utf16 text and UTF-8 strings, it gives every answer right and none wrong, refuses no valid pattern, accepts no invalid one and reaches no limit. CI runs the whole data through every build on its platform.

Install

pie install corvus-dotnet/corvus-regex-ecma262

PIE downloads the build for the PHP it installs into, and enables the extension.

Getting started

use Corvus\RegexEcma262\{MatchResult, Regex, Utf16};

// The flags are those of new RegExp($pattern, $flags).
$date = new Regex('(?<year>\d{4})-(?<month>\d{2})', 'gu');

$date->test('due 2026-10');                              // true
$m = $date->exec('due 2026-10');
$m->group('year');                                       // '2026'
[$m->start(), $m->end()];                                // [4, 11]

// Every match of a global search, as matchAll makes it.
array_map(fn(MatchResult $m) => $m->group(), $date->matchAll('2026-10, 2027-01'));  // ['2026-10', '2027-01']

// replace replaces every match with the g flag, and the first without it; a callable may give each replacement.
$date->replace('2026-10, 2027-01', '$<month>/$1');       // '10/2026, 01/2027'
$date->replace('2026-10', fn(MatchResult $m) => $m->group('month'));  // '10'

// split splices in what the groups captured; a group that did not take part is null.
(new Regex('\s*(;)\s*'))->split('a ; b;c');              // ['a', ';', 'b', ';', 'c']

// UTF-16 text, positions in code units as in JavaScript.
$date->exec(Utf16::fromUtf8('café 2026-12'))->start();   // 5

The API

new Regex($pattern, $flags = '', $timeout = null, $edition = 'current', $unicode_version = null) Compiles a pattern as new RegExp($pattern, $flags) does. $timeout is the match timeout in seconds (0 for none; null for the default). $edition and $unicode_version are below.
Regex::setDefaultTimeout($seconds), Regex::defaultTimeout() The timeout of every pattern compiled without one: null (no limit) unless set.
Regex::isValid($pattern, $flags = '', $edition = 'current', $unicode_version = null) Whether a pattern is valid, without compiling it.
test($subject, $offset = 0, $timeout = null) RegExp.prototype.test with lastIndex at $offset (at $offset alone with y).
exec($subject, $offset = 0, $timeout = null) The first match at or after $offset, as exec finds it: a MatchResult, or null.
matchAll($subject) A global search as matchAll makes it: an array of MatchResult.
countMatches($subject) The number of matches of a global search, without making them.
replace($subject, $replacement) String.prototype.replace: the first match, or every match with g; a template or a callable.
replaceAll($subject, $replacement) String.prototype.replaceAll: every match.
split($subject, $limit = null) String.prototype.split: an array of strings, null for a group that did not take part.
source(), flags(), timeout(), edition(), unicodeVersion(), groupCount(), groupNames() What the pattern is.
Regex::unicodeVersions(), Regex::DEFAULT_UNICODE_VERSION, Regex::LATEST_UNICODE_VERSION The versions of Unicode the extension carries, [13, 17, 18], the default (17) and the latest (18).

A MatchResult has group($g = 0), start($g = 0) and end($g = 0) (a group by number or name; null when it did not take part), groups() (the array preg_match fills: the match, then each group) and namedGroups(). A subject is a string (UTF-8; one that is not throws ValueError) or a Utf16, built from UTF-16LE bytes or with Utf16::fromUtf8(); the text of a group of a Utf16 subject is a Utf16. Without u or v a pattern is matched by UTF-16 code unit, so over UTF-8 a match can start or end between the halves of a character beyond the Basic Multilingual Plane; such a position is the character's third byte, and the half is written as U+FFFD.

Errors. InvalidPatternException, InvalidFlagsException, PatternTooLargeException and MatchLimitException (a match that reached its timeout or its cap on backtracking memory) are each a RegexException, an Exception.

Timeouts. A pattern has a match timeout, as a Regex of .NET has: new Regex($pattern, $flags, 1.0), or for every pattern compiled after it Regex::setDefaultTimeout(1.0). Every method that matches gives each of its matches the timeout and throws MatchLimitException at it; test and exec take a $timeout of their own in its place. Without a timeout (the default) a match has no limit of time or of memory, so a program that matches a pattern or a subject it did not write should set one.

The edition and the version of Unicode. Two settings, each independent of the other, are settled when a pattern is compiled and cost a match nothing:

  • edition: 'es2020' reads the pattern with the grammar of ECMAScript 2020, which JSON Schema 2020-12 names. It refuses what later editions added, and nothing else: the d and v flags (InvalidFlagsException), modifier groups such as (?i:...), a group name used by two groups, and a property name or value that is not in that edition's tables, such as \p{Script=Yezidi} (each InvalidPatternException). A pattern it accepts has the same answers as in today's grammar. 'current', the default, is today's grammar.
  • unicode_version: is the version of Unicode whose tables the pattern is read with: the members of each \p{...}, the characters of a group name, and case folding. It is null (the default: the version of the V8 the answers are held to, 17 today), 13, 17, 18 or 'latest' (18 today). unicodeVersion() gives the version it resolved to.

Either outside those values throws a ValueError.

$re = new Regex('^\p{L}+$', 'u', 1.0, edition: 'es2020', unicode_version: 13);

Benchmarks

The Industry workloads of dotnet/performance, this extension against PHP's PCRE functions and V8, are on the central page, corvus-oss.org/corvus-regex-ecma262-benchmarks, measured nightly on one runner with the engines of every other source.

Repository

  • src/lib.rs: the extension (ext-php-rs, over the crate); package.ps1: builds and packages it as PIE expects.
  • tests/api.php: the tests; tools/dataset.php: the test-data runner.
  • tools/bench: the benchmark harness and the script the image runs; Dockerfile: the image the central page runs.

A release is tagged with its version alone (0.1.1), as Composer and PIE read it, and carries every build. A change to the benchmark harness alone needs no version: it is a harness release, gh release create harness-<version>.<n> --latest=false, which publishes the harness image and attaches no build (Composer and PIE ignore the tag, which is not a version).

Releasing

Every merged pull request releases what it changed, unless it is labelled no_release. auto-release.yml runs tools/release/release.ps1 on the merge commit: when the extension changed since its last tag (.cargo/, Cargo.toml, Cargo.lock, composer.json, package.ps1 or src/), it makes a GitHub release tagged with the next patch version alone (0.2.2, as Composer and PIE read it), and php-publish.yml attaches every build to it: php-build.yml writes the version into Cargo.toml and Cargo.lock (which hold 0.0.0; tools/release/stamp.ps1), builds and tests the extension for each PHP, platform and thread-safety mode, and the archives are named as PIE looks for them. Packagist reads the new tag. harness-image.yml publishes the harness image under the same tag. When only the harness changed (Dockerfile, tools/bench/, tools/benchmarks/), the release is harness-<version>.<n>, which publishes the image alone. A pull request that needs a minor or major version sets nextVersion in .github/release.json (a minor version is a breaking change). A pull request labelled no_release releases nothing, and its changes ship with the next release.

Licence

Apache-2.0. See LICENSE.