shape

The regexp subset#

Every regular expression a shape carries is read by three engines: the JavaScript engine in TypeScript, RE2 (regexp) in Go and the regex crate in Rust. They agree only on a subset of syntax, and read some of what they share differently (\d is ASCII in JavaScript and RE2 but Unicode in the regex crate; \s is ASCII in RE2 and Unicode elsewhere). So a shape holds every pattern to one subset, checks it at build, and rewrites it for the engine at hand. The same pattern then matches the same strings in every language, and the text as written is what renders, exports to JSON Schema, and appears in messages.

This applies wherever a pattern enters a shape: a /re/ in the string form, a RegExp, *regexp.Regexp or Regex given to a spec or to Check, and a JSON Schema pattern on import.

What is in the subset#

construct meaning
a literal character, ASCII or not itself, one character being one unit in every engine
\\ \^ \$ \. | \? \* \+ \( \) \[ \] \{ \} \/ that character; \- only inside a class
\t \n \r \f \v, \xHH tab, newline, return, form feed, vertical tab, the byte HH
. any character but a newline (\n)
\d \w \s [0-9], [A-Za-z0-9_], [ \t\n\r\f\v]: ASCII, whatever the engine's default
\D \W \S the negations, outside a character class only
[abc] [a-z] [^abc] a character class, with ranges; \d \w \s and the preceding escapes inside it; a - first or last is literal
^ $ the start and end of the whole string (no multi-line mode)
\b \B an ASCII word boundary, and not one; outside a class only
( ) (?: ) a capturing and a non-capturing group
a|b alternation
* + ? {n} {n,} {n,m}, each with a ? suffix the quantifiers, greedy and lazy

What is not, and why#

construct refused as
flags (/re/i, new RegExp(re, 'g'), (?i)) flags are not supported; inline flags are rejected too, in the next table
(?= (?! (?<= (?<! (?<name> (?P<name> (?i) lookaround, named groups and inline flags are not in the subset (RE2 has no lookaround or backreferences)
\1 and other escapes: \u, \p, \a, \e, \0, \c, \Q, \A, \z escape \X is not in the subset
[[:alpha:]] POSIX classes are not in the subset (JavaScript reads it as a class of characters)
[^] [] empty character class (JavaScript alone accepts [^])
[a&&b] [a--b] [a~~b] class set operators (&&, --, ~~) are not in the subset (the regex crate reads them as set operations)
[ inside a class [ must be escaped inside a character class
\D \W \S inside a class \D, \W and \S are not allowed inside a character class
\b inside a class \b and \B are not allowed inside a character class (a backspace in JavaScript)
[\d-z] a range cannot start or end at a class shorthand
a lone { or } lone quantifier brace (a literal in RE2, an error in JavaScript)
a**, *a, a??? nothing to repeat
\- outside a class, \x4 escape \- is only allowed inside a character class, \x needs two hex digits
//, [a, (a, a), a], a trailing \ empty pattern, unterminated character class, unbalanced parentheses, unescaped ], trailing backslash

The full text is Shape: invalid regexp /pattern/: reason, the same in every language. In TypeScript it is thrown when the shape is built; in Go and Rust the string form returns it as the parse error, and a compiled regexp given to a spec or to Check makes a node that reports it at validation, as a builder given a wrong argument does.

How the engines are made to agree#

The scanner rewrites a pattern before compiling it: \d, \w and \s become explicit ASCII classes, . becomes [^\n], and the JavaScript engine is given the u flag so that . and a class see a whole character, as the RE2 engines do. The rewrite is internal: stringify, the JSON Schema export and error messages all show the pattern as written.

Writing a portable pattern#

Gates#

Every row of test/regexp.tsv runs through all three implementations, and the differential harness compares the refusals' text. The tables in ts/test/regexp.test.ts, go/regexp_subset_test.go and rs/src/regexp.rs pin the same cases per language.