Metadata-Version: 2.5
Name: ecma-regex
Version: 0.1.0
Summary: ECMA-262 regular expressions for Python: parse, translate to re, and search with JavaScript semantics
Project-URL: Homepage, https://github.com/handrews/py-json-schema-engine/tree/main/packages/ecma-regex
Project-URL: Repository, https://github.com/handrews/py-json-schema-engine
Project-URL: Issues, https://github.com/handrews/py-json-schema-engine/issues
Author: Henry Andrews
License-Expression: MIT
License-File: LICENSE
Keywords: ecma-262,ecmascript,javascript,regex,regexp,unicode
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Text Processing
Requires-Python: >=3.12
Provides-Extra: regex
Requires-Dist: regex; extra == 'regex'
Description-Content-Type: text/markdown

# ecma-regex

ECMA-262 regular expressions for Python: parse them, translate them to `re`
(or `regex`), and match with JavaScript semantics.

Several widely used data formats specify their regular expressions as
ECMA-262 patterns — JSON Schema above all. A Python program that hands
those patterns straight to `re` does not get an error; it gets *different
answers*, quietly, for `^`, `$`, `.`, `\d`, `\w`, `\s` and `\b`, and a hard
failure for `\p{...}`. This package closes that gap.

It has **no dependencies** and imports nothing from any consumer. It
happens to power [json-schema-engine][engine], but it does not depend on
it, and it is useful anywhere an ECMA-262 pattern has to run under Python.

[engine]: https://github.com/handrews/py-json-schema-engine

Produced by Henry Andrews via Claude Code.

**Status: `0.1.0`, the first documented release.** The README and docstrings
are audited against the code, and every README example is executed by the
test suite. APIs may still change before `1.0`.

## Install

```sh
pip install ecma-regex
# optional: the `regex` backend, which adds variable-width lookbehind
# and native \p{Script=...} support
pip install 'ecma-regex[regex]'
```

Python 3.12+.

## Usage

```python
import re

import ecma_regex

pattern = ecma_regex.compile(r"^\p{Letter}+$")
assert pattern.search("olé") is True
assert pattern.search("olé1") is False
assert isinstance(pattern.translated, str)  # the emitted `re` pattern
assert isinstance(pattern.compiled, re.Pattern)  # the underlying re.Pattern

assert ecma_regex.compile("^a+$").search("aaa\n") is False  # re says True
assert ecma_regex.compile("f.o").search("f\ro") is False  # re says True
assert ecma_regex.compile(r"\d").search("٣") is False  # re says True
assert ecma_regex.compile("^b$", flags="m").search("a\u2028b") is True
```

`search` has `RegExp.prototype.test` semantics: unanchored, boolean.

Lower-level entry points, if you want the pieces:

```python
tree = ecma_regex.parse(r"(?<year>\d{4})-\d{2}")  # an AST
assert isinstance(tree, ecma_regex.Pattern)

source = ecma_regex.translate(tree)  # a backend pattern string
assert source == "(?P<year>[0-9]{4})-[0-9]{2}"

flags = ecma_regex.translate_flags(tree)  # re.IGNORECASE, or 0
assert flags == 0

depth = ecma_regex.star_height(tree)  # ReDoS screening
assert depth == 0
assert ecma_regex.star_height(ecma_regex.parse("a+")) == 1
assert ecma_regex.star_height(ecma_regex.parse("(a+)+")) == 2
```

`star_height` reports the nesting depth of unbounded quantifiers — `a+` is
1, `(a+)+` is 2 — which is the classic necessary condition for
catastrophic backtracking. It over-reports; it is a screen, not a proof.

Nothing is cached at the `compile` level. Put your own cache in front of it
if you compile the same pattern repeatedly.

## Scope

The dialect is ECMA-262 §22.2 with the **`u` flag**: the Unicode-mode
pattern grammar. Annex B's web-compatibility leniencies (legacy octal
escapes, quantifiable assertions, a bare `{` as a literal, identity escapes
of arbitrary characters) are early errors under `u`, and they are errors
here too. `a{,3}`, `a**` and `(?=a)*` all raise.

| Flag | Status |
| --- | --- |
| `i` | supported (`re.IGNORECASE` at compile time) |
| `m` | supported (spelled out with lookarounds, not `re.MULTILINE`) |
| `s` | supported (spelled out, not `re.DOTALL`) |
| `u` | accepted and always implied |
| `g`, `y`, `d`, `v` | rejected — this models one stateless match |

Everything the grammar allows is parsed: lookbehind, backreferences, named
groups and `\k<name>`, `\uXXXX` (surrogate pairs combined), `\u{...}`,
`\xHH`, `\cX`, `\0`, `\t\n\v\f\r`, and `\p{...}` / `\P{...}`.

Invalid patterns raise `EcmaRegexSyntaxError`; valid-but-untranslatable
ones raise `UnsupportedPatternError`. Both subclass `EcmaRegexError` and
carry a `position`.

## Divergence table

What the translation fixes — each row is a silent change of verdict if the
pattern goes to `re` unchanged.

| Construct | Python `re` | ECMA-262 (what is emitted) |
| --- | --- | --- |
| `^` / `$` | `$` also matches before a final `\n` | `\A` / `\Z` |
| `^` / `$` with `m` | line breaks are `\n` only | lookarounds over LF, CR, U+2028, U+2029 |
| `.` | excludes `\n` only | excludes LF, CR, U+2028, U+2029 |
| `.` with `s` | `re.DOTALL` | `[\s\S]` |
| `\d` | any Unicode decimal digit | `[0-9]` |
| `\w` | any Unicode word character | `[A-Za-z0-9_]` |
| `\s` | a different set; misses U+FEFF, includes U+001C–U+001F | ECMA WhiteSpace ∪ LineTerminator |
| `\b` / `\B` | Unicode word boundary | ASCII word boundary, as explicit lookarounds |
| `\D` `\W` `\S` | as above, negated | explicit complement ranges |
| `\D` `\W` `\S` **inside a class** | not expressible | explicit complement ranges |
| `\p{...}` / `\P{...}` | `re.error` | explicit ranges (`re`) or native (`regex`) |
| `(?<name>…)` | different spelling | `(?P<name>…)`, with `$` in names rewritten |
| `\k<name>` | different spelling | `(?P=name)` |
| literal `{`, `#`, `-`, … | may be metacharacters | escaped |

## Residual divergences

These cannot be closed inside a backend pattern, so they are documented
rather than fixed.

| Area | Difference |
| --- | --- |
| Case folding | `i` compiles with `re.IGNORECASE`, which is Python's *full* case folding; ECMA-262 `u` mode uses *simple* case folding. They agree except on a handful of code points. |
| `\b` with `i` and `u` | ECMA-262 adds U+017F and U+212A to the word set in that combination; this package does not. |
| Variable-width lookbehind | `re` requires a fixed width, so `(?<=ab?)c` raises `UnsupportedPatternError` on the `re` backend. It works on `regex`. |
| `\p{Script=…}`, `\p{Script_Extensions=…}` | Parsed, but the standard library ships no script data: translatable only on the `regex` backend, and script *names* are not validated. |
| Some binary properties | `Alphabetic`, `Math`, `Emoji`, `Cased`, … are recognized as valid syntax but not derivable from the standard library; they raise `UnsupportedPatternError` on `re` and are emitted natively on `regex`. |
| Unicode version | Property sets come from the running interpreter's `unicodedata`, not from whatever version a given JavaScript engine ships. |

Properties that *are* derivable everywhere: every `General_Category` value
(including `\p{L}`, `\p{Letter}`, `\p{General_Category=Nd}`) plus `ASCII`,
`ASCII_Hex_Digit`, `Any`, `Assigned`, `Bidi_Control`, `Bidi_Mirrored`,
`Hex_Digit`, `Join_Control`, `Lowercase`, `Noncharacter_Code_Point`,
`Regional_Indicator`, `Uppercase`, `White_Space`, `XID_Continue`,
`XID_Start`.

## Backends

| Backend | Notes |
| --- | --- |
| `re` (default) | Standard library, no dependencies. `\p{...}` becomes an explicit range class. |
| `regex` | Optional extra. Keeps `\p{...}` native, allows variable-width lookbehind, supports scripts. |

```python
greek = ecma_regex.compile(r"\p{Script=Greek}+", backend="regex")
assert greek.search("αβγ") is True
assert greek.search("abc") is False
```

Neither backend is linear-time; both backtrack. Use `star_height` to screen
untrusted patterns.

### Cost

The first `\p{...}` translated on the `re` backend scans every code point
to build its range list — about 40 ms on CPython 3.12. The result is cached
process-wide, so later uses are dict hits, and every later
`General_Category` property reuses the same scan.

## License

MIT.
