Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4951c966d4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.engine-build/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
@@ -9,4 +10,3 @@ test-results/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
public/engines/pcre2/pcre2.mjs
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.0 — 2026-07-27
|
||||
|
||||
- Open Quick reference and Capabilities as populated, accessible in-viewport
|
||||
dialogs with keyboard dismissal, focus handling and explicit empty states.
|
||||
- Add flavour-neutral version, flag, option and execution-worker registries
|
||||
and expose only the ECMAScript and fully wired PCRE2 runtimes.
|
||||
- Validate flavour/version/options consistently across workbench requests,
|
||||
projects and test suites, including legacy schema-v1 identities.
|
||||
- Add a production local-first Corpus / Apply workspace with bounded UTF-8
|
||||
multi-file and pasted-text input, sequential killable worker jobs, progress,
|
||||
cancellation, explicit whole-document/independent-line semantics, preserved
|
||||
line separators, bounded capture extraction and replacement previews,
|
||||
per-document exact/partial summaries, content-free JSON/CSV/NDJSON reports
|
||||
and privacy-gated exact-output downloads and bounded ZIP export.
|
||||
- Keep corpus content and results ephemeral: project schema v1 stores only the
|
||||
selected workspace mode and rejects injected corpus payloads.
|
||||
- Add a deterministic, offline PCRE2 10.47 WebAssembly runtime with pinned
|
||||
source/toolchain identities, provenance, licences and checksums.
|
||||
- Add bounded PCRE2 compile, match and native-substitution ABI calls with
|
||||
match/depth/heap caps, copied caller-owned records, cleanup on every exit,
|
||||
UTF-8-to-UTF-16 range normalization and lone-surrogate rejection.
|
||||
- Run PCRE2 in a dedicated killable/recoverable worker, expose its real
|
||||
flavour-specific flags and options, and add lexical syntax/replacement
|
||||
coverage, official quick reference, conformance and browser tests.
|
||||
- Add an ABI-v3 PCRE2 automatic-callout operation with caller-owned event and
|
||||
mark buffers, dual event/byte caps, native truncation stop, exact UTF-8 and
|
||||
UTF-16 positions, a separate killable worker and a bounded viewer that keeps
|
||||
reported fields distinct from derived movement labels.
|
||||
- Add ECMAScript-scoped advisory static risk diagnostics plus cancellable
|
||||
bounded growth probes and cold/warm native-engine benchmarks with median/p95,
|
||||
separate timing/outcome plots, runtime identity and strict
|
||||
input/output/wall limits.
|
||||
- Add an ECMAScript/PCRE2 comparison orchestrator with exact per-side requests,
|
||||
independent worker timeout outcomes, normalized-range match/capture
|
||||
alignment, replacement differences and explicit non-comparable states.
|
||||
- Add a reviewed PCRE2 10.47 8-bit C17 generator with exact UTF-8 byte arrays,
|
||||
native/host limits, error handling, deterministic source identities and
|
||||
compile/execute golden gates against the named toolchain.
|
||||
- Add deterministic, cancellable failing-subject minimization for saved-test
|
||||
failures, capture ranges, exact engine timeouts and ECMAScript/PCRE2
|
||||
mismatches, with real-worker verification, explicit budgets and honest
|
||||
transform-local minimality.
|
||||
- Add deterministic, seed-addressed ECMAScript positive/negative case
|
||||
generation from the normalized AST, with bounded synthesis, actual-engine
|
||||
verification, explicit unsupported coverage, cancellable workers and
|
||||
provenance-preserving unit-test integration.
|
||||
- Add an idempotent grammar-backed ECMAScript literal/control formatter with an
|
||||
exact preview, separate source/candidate syntax and engine workers, capture
|
||||
shape checks, applicable unit-test replay, explicit inconclusive states and a
|
||||
mandatory confirmation before apply.
|
||||
|
||||
## 0.1.0 — 2026-07-24
|
||||
|
||||
- Added the open-source ECMAScript 2025 syntax provider using regexpp 4.12.2.
|
||||
|
||||
19
LICENSES/Emscripten-MIT.txt
Normal file
19
LICENSES/Emscripten-MIT.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2018 Emscripten authors (see AUTHORS in Emscripten)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
104
LICENSES/PCRE2.txt
Normal file
104
LICENSES/PCRE2.txt
Normal file
@@ -0,0 +1,104 @@
|
||||
PCRE2 Licence
|
||||
=============
|
||||
|
||||
| SPDX-License-Identifier: | BSD-3-Clause WITH PCRE2-exception |
|
||||
|---------|-------|
|
||||
|
||||
PCRE2 is a library of functions to support regular expressions whose syntax
|
||||
and semantics are as close as possible to those of the Perl 5 language.
|
||||
|
||||
Releases 10.00 and above of PCRE2 are distributed under the terms of the "BSD"
|
||||
licence, as specified below, with one exemption for certain binary
|
||||
redistributions. The documentation for PCRE2, supplied in the "doc" directory,
|
||||
is distributed under the same terms as the software itself. The data in the
|
||||
testdata directory is not copyrighted and is in the public domain.
|
||||
|
||||
The basic library functions are written in C and are freestanding. Also
|
||||
included in the distribution is a just-in-time compiler that can be used to
|
||||
optimize pattern matching. This is an optional feature that can be omitted when
|
||||
the library is built. The just-in-time compiler is separately licensed under the
|
||||
"2-clause BSD" licence.
|
||||
|
||||
|
||||
COPYRIGHT
|
||||
---------
|
||||
|
||||
### The basic library functions
|
||||
|
||||
Written by: Philip Hazel
|
||||
Email local part: Philip.Hazel
|
||||
Email domain: gmail.com
|
||||
|
||||
Retired from University of Cambridge Computing Service,
|
||||
Cambridge, England.
|
||||
|
||||
Copyright (c) 1997-2007 University of Cambridge
|
||||
Copyright (c) 2007-2024 Philip Hazel
|
||||
All rights reserved.
|
||||
|
||||
### PCRE2 Just-In-Time compilation support
|
||||
|
||||
Written by: Zoltan Herczeg
|
||||
Email local part: hzmester
|
||||
Email domain: freemail.hu
|
||||
|
||||
Copyright (c) 2010-2024 Zoltan Herczeg
|
||||
All rights reserved.
|
||||
|
||||
### Stack-less Just-In-Time compiler
|
||||
|
||||
Written by: Zoltan Herczeg
|
||||
Email local part: hzmester
|
||||
Email domain: freemail.hu
|
||||
|
||||
Copyright (c) 2009-2024 Zoltan Herczeg
|
||||
All rights reserved.
|
||||
|
||||
The code in the `deps/sljit` directory has its own LICENSE file.
|
||||
|
||||
### All other contributions
|
||||
|
||||
Many other contributors have participated in the authorship of PCRE2. As PCRE2
|
||||
has never required a Contributor Licensing Agreement, or other copyright
|
||||
assignment agreement, all contributions have copyright retained by each
|
||||
original contributor or their employer.
|
||||
|
||||
|
||||
THE "BSD" LICENCE
|
||||
-----------------
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notices,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notices, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the University of Cambridge nor the names of any
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
EXEMPTION FOR BINARY LIBRARY-LIKE PACKAGES
|
||||
------------------------------------------
|
||||
|
||||
The second condition in the BSD licence (covering binary redistributions) does
|
||||
not apply all the way down a chain of software. If binary package A includes
|
||||
PCRE2, it must respect the condition, but if package B is software that
|
||||
includes package A, the condition is not imposed on package B unless it uses
|
||||
PCRE2 independently.
|
||||
197
README.md
197
README.md
@@ -8,21 +8,45 @@ Patterns, replacement templates, test text and project files stay in the
|
||||
browser. There is no account, backend, upload, remote execution, telemetry,
|
||||
runtime CDN, remote font, or cloud-save service.
|
||||
|
||||
## Current milestone
|
||||
## Current release — 0.2.0
|
||||
|
||||
Version 0.1.0 is the first production flavour slice: ECMAScript.
|
||||
The v0.2.0 release contains two real production engine slices: ECMAScript and
|
||||
PCRE2 10.47.
|
||||
|
||||
- Syntax acceptance: `@eslint-community/regexpp` 4.12.2, ECMAScript 2025
|
||||
grammar; application-owned structural explanations are partial and
|
||||
feature-matrix tested.
|
||||
- Execution engine: the current browser's native ECMAScript `RegExp`.
|
||||
- Native and editor offsets: UTF-16 code units.
|
||||
- PCRE2 execution and substitution: pinned official PCRE2 10.47 compiled to a
|
||||
self-hosted 8-bit WebAssembly pack with UTF/UCP always enabled.
|
||||
- PCRE2 native UTF-8 byte ranges are retained and normalized to exact editor
|
||||
UTF-16 ranges; inputs with lone surrogates are rejected instead of rewritten.
|
||||
- PCRE2 match-step, depth and heap limits are configurable inside hard caps.
|
||||
- PCRE2 automatic callouts are copied through a separate ABI operation and
|
||||
displayed from a dedicated killable trace worker under 50,000-event and
|
||||
10 MiB serialized-data hard caps.
|
||||
- Parsing and execution run in separate workers.
|
||||
- Actual execution can be terminated by killing its worker.
|
||||
- The worker is recreated after timeout, crash or cancellation.
|
||||
- Capture ranges use the engine's `d` indices result.
|
||||
- Named, numbered, optional, empty and repeated-final captures are represented.
|
||||
- Match, replacement, typed list/extraction and unit-test modes are available.
|
||||
- Match, replacement, typed list/extraction, corpus/apply and unit-test modes
|
||||
are available.
|
||||
- ECMAScript-specific advisory static risk findings, bounded dynamic growth
|
||||
probes and cold/warm benchmarks run locally; timing and growth execution is
|
||||
isolated in a separately killable worker.
|
||||
- ECMAScript and PCRE2 can be compared through two exact, independently timed
|
||||
requests without translating patterns, flags or replacements. Incomplete or
|
||||
rejected sides are explicitly not comparable.
|
||||
- The reviewed PCRE2 C17 generator emits exact UTF-8 byte arrays, configured
|
||||
limits and error handling for the PCRE2 10.47 8-bit API.
|
||||
- A cancellable, deterministic subject minimizer verifies saved-test failures,
|
||||
capture-range failures, exact timeouts and ECMAScript/PCRE2 mismatches against
|
||||
the selected real workers under explicit evaluation and wall budgets.
|
||||
- Deterministic ECMAScript test-case generation samples the normalized AST from
|
||||
an explicit seed, reports structural coverage and unsupported constructs, and
|
||||
labels cases only after verification by the selected real engine worker.
|
||||
- Project JSON import/export and optional IndexedDB save are available.
|
||||
- Interactive test text is not persisted by default.
|
||||
|
||||
@@ -40,39 +64,67 @@ hierarchy uses syntactic capture nesting while preserving actual engine ranges.
|
||||
ECMAScript exposes only the final retained capture of a repeated group; Regex
|
||||
Tools says so and never invents capture history.
|
||||
|
||||
Browser ECMAScript engines do not expose their internal backtracking trace.
|
||||
Version 0.1.0 therefore presents no ECMAScript execution trace and no
|
||||
educational trace disguised as one.
|
||||
Browser ECMAScript exposes no actual engine trace. PCRE2 has a separate bounded
|
||||
automatic-callout viewer. Its event fields are reported by PCRE2, while
|
||||
“forward”, “same position” and “apparent backtrack” are visibly derived only
|
||||
from adjacent reported subject positions. The callout stream is not described
|
||||
as a complete record of every internal engine action.
|
||||
|
||||
## Workbench modes
|
||||
|
||||
- **Match** — live highlighting, extraction tree and bounded capture table.
|
||||
- **Replace** — native ECMAScript `RegExp` matches with bounded,
|
||||
specification-compatible string substitution; output completeness is
|
||||
reported separately from bounded per-match original/result, changed-range and
|
||||
token-contribution previews that synchronize the replacement, pattern,
|
||||
subject and output editors.
|
||||
- **Replace** — engine-native bounded substitution for ECMAScript and PCRE2;
|
||||
output completeness is reported separately from bounded per-match
|
||||
original/result, changed-range and token-contribution previews that
|
||||
synchronize the replacement, pattern, subject and output editors.
|
||||
- **List** — non-executable typed templates with text, CSV, JSON and NDJSON
|
||||
export; export is disabled when engine collection or bounded rendering is
|
||||
incomplete.
|
||||
- **Corpus / Apply** — bounded multi-file UTF-8 and pasted-text input processed
|
||||
sequentially in killable workers. Whole-document and independent-line
|
||||
semantics are explicit; line apply preserves original separators. Progress,
|
||||
cancellation, bounded capture samples, replacement previews, per-document
|
||||
exact/partial summaries, content-free JSON/CSV/NDJSON reports and
|
||||
privacy-gated exact-output ZIP/downloads are available. Original files are
|
||||
never modified.
|
||||
- **Unit tests** — editable and cloneable versioned presence, count, full-match,
|
||||
capture, replacement, timing and expected-timeout assertions. Capture a
|
||||
complete current result, select only cases to rerun, filter failures, and
|
||||
import/export a standalone validated JSON suite; execution stays in isolated
|
||||
workers.
|
||||
|
||||
Corpus, comparison, debugging, analysis, generation, minimization, formatting
|
||||
and code generation remain roadmap items. Controls for them are not exposed in
|
||||
this release.
|
||||
The auxiliary **PCRE trace**, ECMAScript **Analysis**, **Compare & code**,
|
||||
**Generate cases**, **Minimize** and **Format** workspaces are separate from
|
||||
normal matching, replacement, tests and corpus execution. Trace collection is
|
||||
never included in benchmark measurements.
|
||||
Comparison retains native ranges while aligning actual results by normalized
|
||||
editor UTF-16 ranges. Agreement is described only as evidence for the current
|
||||
subject, never as proof of pattern equivalence.
|
||||
Case generation is bounded sampling, not a proof of language coverage. Version
|
||||
1 supports only a complete accepted ECMAScript normalized AST; the partial
|
||||
PCRE2 provider is reported as unavailable rather than passed through or
|
||||
relabeled. See
|
||||
[`docs/GENERATED_CASES.md`](docs/GENERATED_CASES.md).
|
||||
Minimization verifies every accepted candidate against the exact selected
|
||||
worker request and claims only local minimality under its documented
|
||||
Unicode-scalar transforms; see
|
||||
[`docs/MINIMIZATION.md`](docs/MINIMIZATION.md).
|
||||
Formatting is currently ECMAScript-only and changes only parser-reported
|
||||
literal slash/control representations. Apply requires independent reparse,
|
||||
actual-engine current-subject/replacement comparison, every applicable exact
|
||||
unit test and explicit confirmation; see
|
||||
[`docs/FORMATTING.md`](docs/FORMATTING.md).
|
||||
|
||||
## Flags and iteration
|
||||
|
||||
Pattern and flags are stored separately; delimiters are presentation only.
|
||||
Native `g` and `y` behavior is preserved. The optional **Scan all** action is
|
||||
explicit. When neither `g` nor `y` is present it adds `g` only to that execution
|
||||
request, visibly records the internal flag, and does not change the saved user
|
||||
flags. The `d` flag may likewise be added internally to obtain exact indices
|
||||
without changing matching semantics.
|
||||
ECMAScript preserves native `g` and `y`; PCRE2 offers the application-level
|
||||
`g` iteration flag plus `i`, `m`, `s`, `x`, `U` and `J`. PCRE2 UTF/UCP mode is
|
||||
mandatory because browser strings are Unicode and partial-byte ranges cannot be
|
||||
represented safely in the editor. The optional **Scan all** action adds only an
|
||||
internal iteration flag to that request and does not change saved flags.
|
||||
ECMAScript may add internal `d` to obtain exact indices without changing match
|
||||
semantics.
|
||||
|
||||
Zero-length global iteration advances using the selected Unicode semantics and
|
||||
cannot loop forever.
|
||||
@@ -82,7 +134,7 @@ cannot loop forever.
|
||||
Important defaults:
|
||||
|
||||
| Limit | Default |
|
||||
| ------------------------------ | -----------------: |
|
||||
| ------------------------------- | -----------------: |
|
||||
| Live parse debounce | 120 ms |
|
||||
| Live execution debounce | 220 ms |
|
||||
| Live execution timeout | 250 ms |
|
||||
@@ -90,6 +142,16 @@ Important defaults:
|
||||
| Advanced maximum timeout | 10 s |
|
||||
| Pattern hard limit | 64 Ki UTF-16 units |
|
||||
| Interactive subject hard limit | 16 MiB |
|
||||
| Corpus documents | 256 |
|
||||
| Per corpus document | 16 MiB |
|
||||
| Aggregate corpus input | 256 MiB |
|
||||
| Independent corpus lines | 100,000 |
|
||||
| Aggregate corpus matches | 100,000 |
|
||||
| Capture summaries per document | 32 |
|
||||
| Samples per capture | 3 |
|
||||
| Retained applied output | 64 MiB |
|
||||
| Applied ZIP | 68 MiB |
|
||||
| Corpus batch wall time | 5 min |
|
||||
| Replacement template | 64 Ki UTF-16 units |
|
||||
| List template | 16 Ki UTF-16 units |
|
||||
| Per-match replacement preview | 200 matches |
|
||||
@@ -104,6 +166,30 @@ Important defaults:
|
||||
| Tests per suite | 1,000 |
|
||||
| Retained message per test | 2 KiB UTF-8 |
|
||||
| Unit-test suite wall time | 60 s |
|
||||
| PCRE2 trace events | 50,000 |
|
||||
| PCRE2 serialized trace | 10 MiB |
|
||||
| Comparison retained differences | 5,000 |
|
||||
| Comparison retained alignments | 2,000 |
|
||||
| Static analysis traversal | 20,000 nodes |
|
||||
| Static analysis findings | 100 |
|
||||
| Growth probe steps | 24 |
|
||||
| Analysis aggregate wall time | 30 s |
|
||||
| Generated verified cases | 48 |
|
||||
| Generated candidate attempts | 192 |
|
||||
| Generation aggregate wall time | 15 s |
|
||||
| Generation aggregate candidates | 1 MiB |
|
||||
| Minimizer subject | 1 MiB UTF-8 |
|
||||
| Minimizer candidate evaluations | 2,000 |
|
||||
| Minimizer aggregate wall time | 60 s |
|
||||
| Formatter validation tests | 1,000 |
|
||||
| Formatter test wall time | 60 s |
|
||||
| Retained minimizer history | 200 |
|
||||
|
||||
Analysis defaults to three warm-up plus 15 measured samples (caps: 100 and
|
||||
1,000), with a 2-second per-sample worker limit that can be raised to 10
|
||||
seconds. Growth generation defaults to 1 MiB, preflights the central 16 MiB
|
||||
subject cap and rejects more than 10,000,000 repetitions. Replacement timing
|
||||
is skipped above an 8 Mi UTF-16-unit output estimate.
|
||||
|
||||
A timeout terminates actual execution. It is reported as **timed out**, never as
|
||||
“no match”. The next request creates a fresh worker.
|
||||
@@ -128,8 +214,12 @@ require separate explicit opt-ins for JSON export and IndexedDB save.
|
||||
Standalone test-suite JSON likewise omits subjects by default, is schema- and
|
||||
field-validated on import, and appends at most 1,000 total tests. Add, update,
|
||||
clone and append-import also preflight the complete in-memory suite against a
|
||||
32 MiB aggregate bound. Corpus content is not supported or persisted in this
|
||||
release. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8
|
||||
32 MiB aggregate bound. Added generated tests retain their validated generator
|
||||
version, seed, candidate ID and intended outcome; editing one removes that
|
||||
provenance. Schema v1 can remember that the corpus workspace was selected, but
|
||||
corpus documents, contents, outputs and results are always ephemeral and
|
||||
excluded from JSON and IndexedDB. Imports that try to inject those fields are
|
||||
rejected. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8
|
||||
JSON-document limit in addition to per-field limits. The latest local save is
|
||||
read through an `updatedAt` index cursor; loading it does not materialize the
|
||||
complete project store.
|
||||
@@ -154,6 +244,9 @@ npm run test:conformance
|
||||
npm run test:browser
|
||||
npm run engines:build
|
||||
npm run engines:verify
|
||||
npm run engines:pcre2:build -- --source-dir /path/to/pcre2 --emcc /path/to/emcc
|
||||
npm run engines:pcre2:verify
|
||||
npm run engines:pcre2:install
|
||||
npm run build
|
||||
npm run toolbox:check
|
||||
npm run check
|
||||
@@ -173,17 +266,34 @@ The deterministic release ZIP can be consumed by Toolbox Portal using an exact
|
||||
artifact URL, manifest ID, version and SHA-256. Portal builds consume the
|
||||
artifact; they do not build Regex Tools source.
|
||||
|
||||
Version 0.1.0 has no WebAssembly. Its minimum CSP needs self-hosted scripts and
|
||||
workers, not `wasm-unsafe-eval`. See
|
||||
[`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md).
|
||||
The PCRE2 worker loads self-hosted JavaScript and WebAssembly only. Deployment
|
||||
must serve `.wasm` as `application/wasm` and allow WebAssembly compilation in
|
||||
`script-src`; see [`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md).
|
||||
|
||||
## Flavour roadmap
|
||||
|
||||
The next flavour is official PCRE2 10.47 WebAssembly. A technical spike from the
|
||||
signed official tag has already demonstrated Unicode matching, named captures,
|
||||
substitution, automatic and explicit callouts, and match/depth/heap limits.
|
||||
PCRE2 is not shipped until the application-owned bridge, byte/UTF-16 offsets,
|
||||
trace caps, browser tests and source/licence material meet the acceptance gate.
|
||||
PCRE2 10.47 is the second implemented flavour. Its application-owned ABI
|
||||
provides bounded compilation, matching, native substitution and separate
|
||||
automatic-callout tracing, copied caller-owned records, deterministic
|
||||
allocation cleanup and explicit result completeness. The exact
|
||||
source/toolchain build is reproducible offline and the verified pack is bundled
|
||||
with its metadata, checksums and licence.
|
||||
|
||||
Generated-case version 1 is ECMAScript-only. It traverses the complete
|
||||
application-owned ECMAScript AST under node, depth, attempt, byte, repetition
|
||||
and wall-time caps, then verifies every retained label through the actual
|
||||
browser `RegExp` adapter. PCRE2 generation remains unavailable until its syntax
|
||||
provider can expose a complete normalized tree.
|
||||
|
||||
Pattern formatting is likewise ECMAScript-only. It consumes the complete
|
||||
regexpp token model and does not reinterpret the partial PCRE2 structure.
|
||||
PCRE2 formatting remains unavailable until a complete grammar-backed
|
||||
implementation passes equivalent idempotence, reparse, engine and test gates.
|
||||
|
||||
The first advertised generated-code target is the PCRE2 10.47 8-bit C API.
|
||||
Its deterministic match and replacement fixtures compile and execute against
|
||||
the exact official release. See
|
||||
[`docs/COMPARISON_AND_CODEGEN.md`](docs/COMPARISON_AND_CODEGEN.md).
|
||||
|
||||
Python, Go, Rust, .NET, Java and legacy PCRE follow only through their actual
|
||||
named runtimes. No flavour is emulated through JavaScript and relabelled.
|
||||
@@ -198,7 +308,7 @@ notices; see [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and
|
||||
No regex101 application source, branding, generated explanation prose,
|
||||
reference text or visual design is copied. RegexLib and RGXP.RU fixtures are not
|
||||
redistributed because a suitable fixture licence was not established. No
|
||||
RegexHub fixture is shipped in v0.1.0.
|
||||
RegexHub fixture is shipped in v0.2.0.
|
||||
|
||||
## Known limitations
|
||||
|
||||
@@ -211,7 +321,17 @@ RegexHub fixture is shipped in v0.1.0.
|
||||
error node, not a fabricated multi-character span or partial provider AST.
|
||||
- Native JavaScript compile errors do not consistently expose a machine-readable
|
||||
source offset; the provider diagnostic supplies the range.
|
||||
- Capture history and actual ECMAScript execution tracing are unavailable.
|
||||
- Capture history is unavailable. Browser ECMAScript exposes no engine trace;
|
||||
PCRE2 exposes bounded automatic callouts, not a complete internal trace.
|
||||
Movement classifications are derived and labelled as such.
|
||||
- Static risk heuristics and generated-input observations apply only to
|
||||
ECMAScript 2025 and are advisory. They are never proof of safety or of a
|
||||
pattern’s general time complexity; analysis settings and results are
|
||||
ephemeral.
|
||||
- PCRE2 structural explanations are deliberately partial. The application
|
||||
recognizes common capture forms and PCRE2-only constructs for reference, but
|
||||
the actual PCRE2 compiler is authoritative. Branch-reset capture numbering is
|
||||
omitted from the explanation rather than guessed.
|
||||
- Capture-table rendering is paged at 200 rows while aggregate collection is
|
||||
bounded separately.
|
||||
- Explanation/extraction trees and editor decorations show bounded 2,000-node
|
||||
@@ -235,8 +355,17 @@ RegexHub fixture is shipped in v0.1.0.
|
||||
- Unit-test failure messages retain at most 2 KiB UTF-8 per case, so 1,000
|
||||
results stay below the 2 MiB log budget. Oversized exact replacement results
|
||||
offer a lightweight `should-match` draft when one is valid and fits the suite.
|
||||
- Benchmark, risk analysis, generated cases, minimization and corpus processing
|
||||
are not in this release.
|
||||
- Corpus input accepts recognized UTF-8 text only. It does not auto-detect
|
||||
legacy encodings or recursively traverse directories.
|
||||
- Corpus capture extraction is a bounded per-document summary: full match plus
|
||||
at most 31 capture groups, three 160-unit samples each. Content-free reports
|
||||
include counts and clipping metadata, never the samples themselves.
|
||||
- Generated cases deliberately sample only ECMAScript and report
|
||||
backreferences, lookaround, boundaries, complex Unicode sets and other
|
||||
unsupported constraints instead of claiming exhaustive coverage.
|
||||
- Pattern formatting is intentionally narrow and ECMAScript-only. Its bounded
|
||||
source/candidate and unit-test validation is evidence for the checked
|
||||
snapshots, not a proof of equivalence over all subjects.
|
||||
|
||||
Architecture, security, performance, provenance and flavour details live in
|
||||
[`docs/`](docs/).
|
||||
|
||||
18
SOURCE.md
18
SOURCE.md
@@ -1,9 +1,10 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Regex Tools
|
||||
- Version: 0.1.0
|
||||
- Release version: `0.2.0`
|
||||
- Release tag: `v0.2.0`
|
||||
- Repository: <https://git.add-ideas.de/zemion/regex-tools>
|
||||
- Release tag: `v0.1.0`
|
||||
- Previous release tag: `v0.1.0`
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Copyright: © 2026 Albrecht Degering
|
||||
|
||||
@@ -19,5 +20,14 @@ runtime or build-time CDN fetch. Complete preferred-form source is the tagged
|
||||
repository. The release archive contains compiled static assets plus legal,
|
||||
source-identity and attribution documents.
|
||||
|
||||
No PCRE2, Python, Go, Rust, .NET, Java or legacy-PCRE runtime is included in
|
||||
version 0.1.0.
|
||||
The explicit offline PCRE2 build is documented in `docs/ENGINE_BUILDS.md`.
|
||||
PCRE2 source is not vendored: the gate accepts only the pinned, clean official
|
||||
10.47 checkout and Emscripten 6.0.4 compiler. Generated staging is ignored; the
|
||||
verified runtime files, deterministic metadata, checksums and exact licence are
|
||||
installed under `public/engines/pcre2/` and included in the application source
|
||||
and build.
|
||||
|
||||
The v0.2.0 release includes PCRE2. Python, Go, Rust, .NET, Java and legacy PCRE
|
||||
remain unimplemented. The v0.1.0 release remains available under its historical
|
||||
tag and artifact coordinates. A future public artifact must bump package,
|
||||
manifest, tag and source identity together; it must not reuse `v0.2.0`.
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# Third-party notices
|
||||
|
||||
Regex Tools original source is GPL-3.0-or-later. The production browser bundle
|
||||
also contains the compatible components below. Exact dependency resolution is
|
||||
recorded in `package-lock.json`.
|
||||
contains the compatible runtime components below.
|
||||
Exact dependency resolution is recorded in `package-lock.json`.
|
||||
|
||||
| Component | Version | Licence | Shipped | Role and source |
|
||||
| -------------------------------- | ------------------------------------------------------------------------ | ---------- | ----------------- | ------------------------------------------------------------------------ |
|
||||
| -------------------------------- | ------------------------------------------------------------------------ | --------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | Runtime | Manifest contract; <https://git.add-ideas.de/zemion/toolbox-sdk> |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | Runtime | Shared shell; same source |
|
||||
| `@eslint-community/regexpp` | 4.12.2 | MIT | Syntax worker | ECMAScript parser; <https://github.com/eslint-community/regexpp> |
|
||||
| CodeMirror packages | state 6.7.1; view 6.43.6; language 6.12.4; commands 6.10.4; search 6.7.1 | MIT | Runtime | Editors; <https://github.com/codemirror> |
|
||||
| `@lezer/highlight` | 1.2.3 | MIT | Runtime | Editor highlighting support; <https://github.com/lezer-parser/highlight> |
|
||||
| React / React DOM | 19.2.7 | MIT | Runtime | User interface; <https://github.com/facebook/react> |
|
||||
| `fflate` | 0.8.3 | MIT | Packaging utility | Deterministic release ZIP; <https://github.com/101arrowz/fflate> |
|
||||
| `fflate` | 0.8.3 | MIT | Runtime and build dependency | Local corpus-output ZIP and deterministic release ZIP; <https://github.com/101arrowz/fflate> |
|
||||
| Emscripten generated runtime | 6.0.4 | MIT | Generated WebAssembly glue | PCRE2 module loader/runtime; <https://github.com/emscripten-core/emscripten> |
|
||||
| PCRE2 | 10.47 | BSD-3-Clause WITH PCRE2-exception | WebAssembly runtime | Pinned official 8-bit engine; <https://github.com/PCRE2Project/pcre2> |
|
||||
| Vite | 8.1.5 | MIT | Generated helpers | Production build; <https://github.com/vitejs/vite> |
|
||||
| Rolldown | 1.1.5 | MIT | Generated helpers | Production bundling; <https://github.com/rolldown/rolldown> |
|
||||
|
||||
@@ -20,8 +22,8 @@ recorded in `package-lock.json`.
|
||||
development-only and are not part of the static runtime bundle.
|
||||
|
||||
`recheck` 4.5.0 and `regexp-ast-analysis` 0.7.1 were inspected but deliberately
|
||||
not installed or shipped. PCRE2 10.47 was used only in an external feasibility
|
||||
spike and is not present in the v0.1.0 artifact.
|
||||
not installed or shipped. PCRE2 source is not vendored; its generated
|
||||
WebAssembly pack is shipped with exact metadata, checksums and licence.
|
||||
|
||||
Corresponding licence texts and copyright notices are in `LICENSES/`. The
|
||||
release package additionally carries the exact Vite and Rolldown legal files
|
||||
|
||||
121
docs/ANALYSIS.md
Normal file
121
docs/ANALYSIS.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Performance and risk analysis
|
||||
|
||||
Regex Tools provides one deliberately scoped analysis implementation:
|
||||
ECMAScript 2025 patterns parsed into the application-owned normalized AST and
|
||||
executed by the browser's native `RegExp` runtime. The analyser does not apply
|
||||
ECMAScript findings to PCRE2 or any other flavour.
|
||||
|
||||
Analysis is advisory. The UI uses “potential risk”, “possibly” and “observed
|
||||
under selected limits”. It never turns an empty finding list into “safe”,
|
||||
“guaranteed linear” or a vulnerability proof.
|
||||
|
||||
## Static analysis
|
||||
|
||||
The application-owned `regex-tools-ecmascript-heuristics` analyser walks at
|
||||
most 20,000 normalized nodes and returns at most 100 findings. Each finding
|
||||
contains:
|
||||
|
||||
- flavour and UTF-16 source range;
|
||||
- stable rule identifier;
|
||||
- static or dynamically observed provenance;
|
||||
- explanation and example risk;
|
||||
- low, medium or high confidence;
|
||||
- limitations;
|
||||
- a suggested investigation.
|
||||
|
||||
The current rules review nested and nullable repetition, ambiguous repeated
|
||||
alternatives, overlapping alternative prefixes, wildcards and backreferences
|
||||
inside repetition, repeated lookaround bodies, unanchored amplification,
|
||||
capture count, nesting depth and replacement-output expansion. These are
|
||||
structural heuristics. They do not model the native engine's complete compiled
|
||||
program, optimizations or calling context.
|
||||
|
||||
## Dynamic growth probes
|
||||
|
||||
The user defines a bounded subject family:
|
||||
|
||||
```text
|
||||
prefix + repeatedFragment.repeat(repetitions) + suffix
|
||||
```
|
||||
|
||||
The byte and UTF-16 size are calculated before the generated string is
|
||||
allocated. The repeated fragment is limited to 4,096 UTF-16 units, each affix
|
||||
to 16,384 units, the run to 24 samples, and repetitions to 10,000,000. The UI
|
||||
defaults to a 1 MiB generated-subject cap even though the central interactive
|
||||
hard cap remains 16 MiB.
|
||||
|
||||
Every probe executes in a dedicated worker. The supervisor terminates and
|
||||
discards that worker on per-sample timeout, cancellation or crash. Runs also
|
||||
stop at:
|
||||
|
||||
- the selected repetition bound;
|
||||
- the selected generated-byte bound;
|
||||
- the selected step bound;
|
||||
- the 30-second aggregate wall bound;
|
||||
- a selected normalized-growth threshold.
|
||||
|
||||
The growth threshold compares the time ratio with the input-size ratio for two
|
||||
adjacent samples. Sub-millisecond measurements below a noise floor do not
|
||||
trigger it. A threshold observation characterizes only those generated inputs
|
||||
and that browser. Timeout, crash, compile rejection and non-match remain
|
||||
distinct states.
|
||||
|
||||
The UI renders time and outcome in separate plots and keeps the exact sample
|
||||
table alongside them.
|
||||
|
||||
## Benchmark method
|
||||
|
||||
A benchmark begins with a fresh analysis worker. Worker creation and its
|
||||
identity response form the separate cold-start measurement. The first native
|
||||
engine use is retained as a cold sample. Configured warm-up samples are
|
||||
discarded; configured measured samples produce the warm statistics.
|
||||
|
||||
Each sample measures these phases separately:
|
||||
|
||||
- `RegExp` construction;
|
||||
- first `exec`;
|
||||
- bounded all-match iteration;
|
||||
- native string replacement when its conservative output bound is acceptable;
|
||||
- all-match throughput and match count.
|
||||
|
||||
All-match iteration retains only counts, not values or captures. It follows the
|
||||
same explicit scan-all, global, sticky and Unicode zero-length advancement
|
||||
semantics as the workbench. The application-added indices flag remains in the
|
||||
effective flags so the measured operation reflects interactive execution.
|
||||
|
||||
Replacement timing is skipped when match collection reaches its bound or a
|
||||
conservative estimate can exceed 8 Mi UTF-16 units. This prevents a benchmark
|
||||
from using an unbounded native replacement merely to obtain a timing.
|
||||
It is also skipped for explicit scan-all with sticky `y`: native
|
||||
`String.replace` processes only one sticky match, while the workbench's
|
||||
explicit scan-all operation can process a contiguous sequence. Reporting that
|
||||
different operation as the same benchmark would be misleading.
|
||||
|
||||
Warm metrics include sample count, minimum, conventional median, nearest-rank
|
||||
p95 and maximum. Milliseconds are shown to two decimal places, with values
|
||||
below 0.01 ms labelled accordingly; this does not imply nanosecond accuracy.
|
||||
The result retains subject size, match count, effective flags, browser runtime
|
||||
identity and engine identity.
|
||||
|
||||
Defaults:
|
||||
|
||||
| Setting | Default | Hard bound |
|
||||
| ----------------------- | ------: | ---------: |
|
||||
| Warm-up samples | 3 | 100 |
|
||||
| Measured samples | 15 | 1,000 |
|
||||
| Per-sample timeout | 2 s | 10 s |
|
||||
| Aggregate wall time | 30 s | 30 s |
|
||||
| Matches per sample | 10,000 | 10,000 |
|
||||
| Replacement output gate | 8 MiU | 8 MiU |
|
||||
|
||||
One subject is not a complete performance characterization. Results from
|
||||
different algorithms, pattern ports, browser engines, WebAssembly runtimes or
|
||||
machines are not directly comparable. Trace-enabled execution is never used
|
||||
for benchmark results.
|
||||
|
||||
## Privacy and persistence
|
||||
|
||||
Patterns, subjects, generated inputs, findings and benchmark samples remain in
|
||||
the browser. No analysis request is uploaded. Analysis settings and results are
|
||||
currently ephemeral and are not added to project JSON or IndexedDB; this also
|
||||
keeps large benchmark subjects out of persisted projects by default.
|
||||
@@ -5,9 +5,32 @@ boundaries.
|
||||
|
||||
```text
|
||||
React workbench
|
||||
├─ Pattern SyntaxSupervisor → syntax.worker → Regexpp provider → normalized AST
|
||||
├─ Pattern SyntaxSupervisor → syntax.worker
|
||||
│ ├─ Regexpp ECMAScript provider → normalized AST
|
||||
│ └─ PCRE2 lexical provider → partial normalized structure
|
||||
├─ Replacement SyntaxSupervisor → syntax.worker → typed replacement tokens
|
||||
├─ EngineSupervisor → ecmascript.worker → native RegExp → match DTOs
|
||||
├─ EngineSupervisor
|
||||
│ ├─ ecmascript.worker → native RegExp → match DTOs
|
||||
│ └─ pcre2.worker → PCRE2 10.47 WASM ABI → match DTOs
|
||||
├─ Pcre2TraceSupervisor → pcre2-trace.worker
|
||||
│ └─ separate ABI-v3 PCRE2_AUTO_CALLOUT operation → bounded trace DTOs
|
||||
├─ AnalysisPanel
|
||||
│ ├─ normalized-AST ECMAScript static heuristics
|
||||
│ └─ AnalysisSupervisor → analysis.worker → native RegExp benchmark/growth probes
|
||||
├─ ComparisonOrchestrator
|
||||
│ ├─ two exact syntax snapshots + independently killable engine workers
|
||||
│ └─ normalized UTF-16 match/capture alignment + explicit incomplete states
|
||||
├─ PCRE2 C17 generator → validated snapshot → exact UTF-8 byte-array source
|
||||
├─ SubjectMinimizer
|
||||
│ ├─ fixed syntax snapshots + exact real-engine failure or comparison oracle
|
||||
│ └─ deterministic Unicode-scalar reducer → bounded local-minimal result
|
||||
├─ CaseGenerationOrchestrator
|
||||
│ ├─ generation.worker → deterministic bounded ECMAScript AST candidates
|
||||
│ └─ EngineSupervisor → selected actual-engine label verification
|
||||
├─ ECMAScript literal/control formatter
|
||||
│ ├─ exact regexpp literal-token ranges → idempotent candidate preview
|
||||
│ └─ PatternFormatValidator → paired syntax + actual-engine workers and exact tests
|
||||
├─ Corpus EngineSupervisor → sequential bounded document jobs → summaries/outputs
|
||||
├─ Test SyntaxSupervisor + EngineSupervisor → isolated cancellable test runs
|
||||
└─ project validator / serializer / IndexedDB persistence
|
||||
```
|
||||
@@ -21,6 +44,59 @@ request ID and worker generation. A supervisor allows one active request,
|
||||
rejects stale responses, terminates on timeout/crash/cancel, and lazily creates
|
||||
a new generation. Timeout is a distinct error state.
|
||||
|
||||
PCRE2 tracing is not an `EngineSupervisor` match or replacement operation. Its
|
||||
own worker compiles with `PCRE2_AUTO_CALLOUT`, copies only complete fixed
|
||||
records and bounded mark bytes, and stops natively at either trace cap.
|
||||
Reported callout fields retain byte positions; movement classifications are a
|
||||
separate derived UI layer. Normal execution, corpus, tests and benchmarks never
|
||||
run through the trace operation.
|
||||
|
||||
Static analysis traverses the normalized ECMAScript tree on the UI side under
|
||||
node/finding caps. Timing and generated-input growth probes use the independent
|
||||
analysis worker. Timeout, cancellation or crash discards that worker; no trace
|
||||
mode is benchmarked.
|
||||
|
||||
Comparison is a separate orchestration layer, not an `EngineSupervisor`
|
||||
operation. It runs explicit ECMAScript and PCRE2 syntax/execution snapshots,
|
||||
retains each engine identity and native range model, and aligns only normalized
|
||||
editor ranges. Timeout, cancellation, compile rejection and truncation remain
|
||||
distinct non-comparable outcomes. Shared patterns are sent unchanged;
|
||||
per-flavour ports are user-owned variants.
|
||||
|
||||
The PCRE2 C generator is application-owned and never implements browser
|
||||
execution. It validates one exact PCRE2 snapshot, emits user strings as
|
||||
independent UTF-8 byte arrays, and states the host-level mappings that the
|
||||
PCRE2 API cannot express directly. Its advertised C17 target is protected by
|
||||
deterministic golden identities and an exact PCRE2 10.47 compile/execute gate.
|
||||
|
||||
Subject minimization is a main-thread bounded reducer whose expensive predicate
|
||||
checks stay in independently terminable engine workers. Fixed pattern syntax
|
||||
is parsed once for capture metadata. The baseline and every accepted candidate
|
||||
retain the exact target and engine identity. Timeout, crash, cancellation,
|
||||
worker failure and incomplete output are separate outcomes. Deterministic
|
||||
ddmin deletion is followed by a fixed-point local sweep over Unicode-scalar
|
||||
deletion and lower-rank canonical replacement; only completion of that sweep
|
||||
permits a transform-local minimality claim.
|
||||
|
||||
Generated-case synthesis and verification are separate trust boundaries. The
|
||||
dedicated generation worker receives only a validated, complete ECMAScript
|
||||
normalized tree plus explicit settings and seed. The main-thread orchestrator
|
||||
then sends each bounded candidate to `EngineSupervisor`; only the selected real
|
||||
adapter can establish a `should-match` or `should-not-match` label. Worker
|
||||
timeout, crash, cancellation, compile rejection, wrong outcome and ordinary
|
||||
failure stay distinct. Settings/results are ephemeral, while explicitly added
|
||||
unit tests retain field-validated generator provenance.
|
||||
|
||||
Formatting is not an engine operation and never rewrites from heuristic text
|
||||
alone. The ECMAScript formatter consumes an exact accepted regexpp snapshot and
|
||||
changes only parser-reported literal/control token ranges. Its validator
|
||||
reparses source and candidate separately, preserves capture shape, runs both in
|
||||
separate actual-engine workers against the current replacement snapshot, then
|
||||
replays every enabled test tied to the exact active pattern/version/flags/options.
|
||||
Any difference, incomplete output, one-sided timeout, identity change or
|
||||
inconclusive worker outcome blocks apply. The preview and observations are
|
||||
ephemeral, and user confirmation is required even after all bounded gates pass.
|
||||
|
||||
Pattern and replacement syntax use independent supervisors. A pattern snapshot
|
||||
is bound to its exact pattern, ordered flags and revision; execution cannot use
|
||||
acceptance or capture metadata from an earlier input. Replacement output is
|
||||
@@ -32,10 +108,27 @@ over those actual matches, the complete replacement-token model and the bounded
|
||||
output. They execute no replacement callback or user code and have separate
|
||||
presentation and token-evaluation limits.
|
||||
|
||||
Native ranges are retained in the engine's declared unit. Browser editor ranges
|
||||
Corpus documents are ephemeral React session state, not project state. A
|
||||
dedicated engine supervisor processes one document at a time and is terminated
|
||||
on cancellation, timeout or workspace exit. Batch orchestration retains only
|
||||
per-document summaries and, for apply runs, bounded outputs. It enforces
|
||||
document-count, per-document byte, aggregate input byte, aggregate match,
|
||||
aggregate output and batch wall-time limits. Exact-output export is unavailable
|
||||
if any document is partial, failed, cancelled or not run.
|
||||
|
||||
Whole-document mode sends each document to the engine once. Independent-line
|
||||
mode segments CRLF, CR, LF, U+2028 and U+2029 without discarding them, executes
|
||||
each logical line as its own subject, and reattaches the exact original
|
||||
separator during apply. This makes anchor and cross-line behavior deliberately
|
||||
different rather than silently rewriting the pattern. Match DTOs are reduced to
|
||||
bounded per-group participation counts and samples before the next document.
|
||||
|
||||
Native ranges are retained in the engine's declared unit. PCRE2 always uses
|
||||
UTF/UCP and exposes UTF-8 byte ranges; its ABI copies scalar records and names
|
||||
out of WebAssembly before returning and retains no code pointer. Browser editor ranges
|
||||
are half-open UTF-16 code-unit ranges. Reusable converters cover UTF-8 byte and
|
||||
Unicode code-point offsets for later flavours, including invalid-boundary
|
||||
rejection and lone-surrogate detection.
|
||||
Unicode code-point offsets, including invalid-boundary rejection and
|
||||
lone-surrogate detection.
|
||||
|
||||
The release boundary is `dist/` plus checked-in legal/source documents. The
|
||||
Portal consumes the resulting immutable ZIP and never imports React source.
|
||||
|
||||
118
docs/COMPARISON_AND_CODEGEN.md
Normal file
118
docs/COMPARISON_AND_CODEGEN.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# ECMAScript/PCRE2 comparison and PCRE2 C generation
|
||||
|
||||
## Comparison contract
|
||||
|
||||
The comparison panel sends two explicit requests: one to the native browser
|
||||
ECMAScript worker and one to the bundled PCRE2 10.47 worker. It does not
|
||||
translate patterns, flags, options or replacement templates.
|
||||
|
||||
Two pattern models are available:
|
||||
|
||||
- **Shared pattern** sends the same string unchanged to both engines.
|
||||
- **Per-flavour variants** retains two explicit ports supplied by the user.
|
||||
|
||||
Flags are selected independently from each flavour's registered allowlist.
|
||||
PCRE2 match, depth and heap bounds are part of its exact request. Both sides
|
||||
share the selected subject, scan-all intent, host result bounds and wall-clock
|
||||
worker timeout.
|
||||
|
||||
Each side retains:
|
||||
|
||||
- its syntax-provider request, acceptance, diagnostics and coverage;
|
||||
- the exact execution or replacement request sent to its worker;
|
||||
- a deterministic display key plus the complete authoritative request;
|
||||
- completion, timeout, cancellation or worker-failure status;
|
||||
- engine, runtime and adapter identity when the engine returns one;
|
||||
- user and effective flags;
|
||||
- native ranges and normalized editor UTF-16 ranges;
|
||||
- compile acceptance, matches, captures and replacement output.
|
||||
|
||||
The orchestrator owns two syntax supervisors and the multi-engine execution
|
||||
supervisor. The two engine calls run concurrently through independently
|
||||
killable flavour workers. A side timeout kills only that worker. The shared
|
||||
Cancel action terminates every active comparison worker. Comparison remains
|
||||
outside `EngineSupervisor`, whose responsibility is one engine operation and
|
||||
lifecycle boundary.
|
||||
|
||||
Match pairs are aligned by equal normalized UTF-16 ranges first. Unpaired
|
||||
matches use an explicitly labelled ordinal fallback. Captures are aligned by
|
||||
normalized range, then capture identity, then an explicitly labelled ordinal
|
||||
fallback. Original native offsets remain attached to both results.
|
||||
|
||||
No equivalence claim is made when syntax or compilation is rejected, a worker
|
||||
times out/fails/is cancelled, results or values are truncated, or replacement
|
||||
output is incomplete. A completed matching result is labelled only as
|
||||
agreement for the current subject; it is evidence, not a proof that two
|
||||
patterns are equivalent. Timeout is never interpreted as “no match.”
|
||||
|
||||
The retained DTO is bounded to 5,000 displayed difference records and 2,000
|
||||
match alignments while still counting all differences in the bounded engine
|
||||
result. The panel renders at most 250 differences and 100 alignments at once.
|
||||
|
||||
## PCRE2 C17 generator
|
||||
|
||||
The first reviewed code-generation target is the PCRE2 10.47 8-bit C API.
|
||||
Other languages remain unavailable until their generators have equivalent
|
||||
escaping, semantic and toolchain gates.
|
||||
|
||||
The generator consumes a validated PCRE2 snapshot and:
|
||||
|
||||
- requires the registered PCRE2 10.47 version and flag/option allowlists;
|
||||
- refuses unpaired UTF-16 surrogates rather than emitting lossy UTF-8;
|
||||
- emits pattern, subject and replacement as independent explicit UTF-8 byte
|
||||
arrays, so quotes, backslashes, newlines, NUL, Unicode, dollar signs and
|
||||
braces cannot escape into C source syntax;
|
||||
- enables mandatory `PCRE2_UTF` and `PCRE2_UCP`;
|
||||
- maps `i`, `m`, `s`, `x`, `U` and `J` to named PCRE2 compile constants;
|
||||
- implements application-level `g`/scan-all behavior explicitly;
|
||||
- handles the PCRE2 empty-match anchored retry before advancing one UTF-8
|
||||
character;
|
||||
- configures native match, depth and heap limits;
|
||||
- enforces match/capture row caps in the generated match loop;
|
||||
- bounds replacement output with a host buffer and withholds output when its
|
||||
post-run result-row bound is exceeded;
|
||||
- reports compile offsets and native API errors and cleans up all PCRE2 state;
|
||||
- rejects compilation against a PCRE2 header other than 10.47 and verifies the
|
||||
runtime version.
|
||||
|
||||
The generated program states limitations that cannot be mapped honestly:
|
||||
|
||||
- PCRE2 has no `g` compile flag;
|
||||
- match/capture rows and replacement-output size are host-application bounds,
|
||||
not PCRE2 matching options;
|
||||
- replacement result-row limits can be checked only after
|
||||
`pcre2_substitute()` has completed;
|
||||
- PCRE2 exposes no native wall-clock timeout, so hard elapsed-time
|
||||
cancellation belongs to the containing process;
|
||||
- C API offsets are UTF-8 bytes, not browser editor UTF-16 units.
|
||||
|
||||
Generated snippets are examples only and are never used to implement the
|
||||
browser runtime.
|
||||
|
||||
## Golden and toolchain gate
|
||||
|
||||
`scripts/pcre2-codegen-golden.test.ts` pins deterministic SHA-256 identities
|
||||
for match and replacement fixtures:
|
||||
|
||||
```text
|
||||
match 22b91d578a449b0ed5c30e6eadad30f19a2bcb26ac9db355c8ec7a69328675da
|
||||
replacement ab90a0d9831ea7b7145aa0072ac352b42baf9a795319d81f531b0f341b04a636
|
||||
```
|
||||
|
||||
The compile/execute gate was run against a host static build from official
|
||||
signed tag `pcre2-10.47`, commit
|
||||
`f454e231fe5006dd7ff8f4693fd2b8eb94333429`. Both generated fixtures compile
|
||||
under C17 with `-Wall -Wextra -Werror`; the match fixture verifies native UTF-8
|
||||
ranges and named captures, and the replacement fixture verifies exact Unicode
|
||||
output and substitution count.
|
||||
|
||||
To repeat the gate with an exact local PCRE2 installation:
|
||||
|
||||
```sh
|
||||
PCRE2_CODEGEN_PREFIX=/path/to/pcre2-10.47-prefix \
|
||||
npm test -- scripts/pcre2-codegen-golden.test.ts
|
||||
```
|
||||
|
||||
The prefix must provide `include/pcre2.h` and
|
||||
`lib64/libpcre2-8.a`. A different static-library location can be supplied
|
||||
through `PCRE2_CODEGEN_LIBRARY`.
|
||||
@@ -1,28 +1,74 @@
|
||||
# Engine builds
|
||||
|
||||
Version 0.1.0 ships no WebAssembly engine.
|
||||
The production application uses the browser's native `RegExp` for ECMAScript
|
||||
and bundles a verified PCRE2 10.47 WebAssembly pack. Normal application builds
|
||||
never download or compile an engine: the reviewed files under
|
||||
`public/engines/pcre2/` are copied into `dist/` and verified there.
|
||||
|
||||
`npm run engines:build` verifies that this release has no external pack to
|
||||
build. After the production build, `npm run engines:verify` rejects any
|
||||
undeclared engine asset and confirms the packaged ECMAScript-only notice.
|
||||
Neither command accesses the network.
|
||||
## Rebuilding PCRE2
|
||||
|
||||
## PCRE2 feasibility record
|
||||
The reproducible build accepts only:
|
||||
|
||||
The next flavour was spiked outside the application repository against official
|
||||
PCRE2 10.47:
|
||||
- PCRE2 tag `pcre2-10.47`, signed tag object
|
||||
`cd007b4466798f66d479d1442a407099e7c40050`, peeled commit
|
||||
`f454e231fe5006dd7ff8f4693fd2b8eb94333429` and tree
|
||||
`81a83a3552bd68d0ea7b7004f8bb6e7892f583ba`;
|
||||
- Emscripten 6.0.4, compiler revision
|
||||
`fe5be6afdff43ad58860d821fcc8572a23f92d19`, from emsdk commit
|
||||
`224ec5f9f2f72f09f9ce0e26d66bae7dbd8b692f`;
|
||||
- CMake 4.3.4 and Ninja 1.13.2;
|
||||
- 8-bit PCRE2 with Unicode enabled and JIT, threads and filesystem disabled.
|
||||
|
||||
- signed tag object `cd007b4466798f66d479d1442a407099e7c40050`;
|
||||
- peeled commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429`;
|
||||
- licence `BSD-3-Clause WITH PCRE2-exception`;
|
||||
- Emscripten 6.0.4;
|
||||
- 8-bit library, Unicode enabled, JIT disabled.
|
||||
The source and compiler checkouts must already exist locally and be completely
|
||||
clean. No command clones, downloads or updates them:
|
||||
|
||||
Node and Chromium tests demonstrated version/config reporting, Unicode,
|
||||
named groups, global substitution, automatic and explicit callouts, and
|
||||
match/depth/heap errors. This was a technical spike only. No spike source,
|
||||
binary or PCRE2 source is included in v0.1.0.
|
||||
```sh
|
||||
npm run engines:pcre2:build -- \
|
||||
--source-dir /absolute/path/to/pcre2 \
|
||||
--emcc /absolute/path/to/emscripten/emcc
|
||||
npm run engines:pcre2:verify
|
||||
npm run engines:pcre2:install
|
||||
```
|
||||
|
||||
Production PCRE2 support remains gated on a reviewed bridge, copied DTOs,
|
||||
allocation cleanup, exact source build, UTF-8/UTF-16 mapping, trace/output caps,
|
||||
worker recovery, deterministic assets, licences and browser conformance.
|
||||
Use `--force` only to replace the exact ignored `.engine-build/pcre2` output.
|
||||
The explicit install command first re-verifies that pack and then atomically
|
||||
replaces only `public/engines/pcre2`.
|
||||
|
||||
The builder verifies Git objects and critical source/compiler hashes,
|
||||
sanitizes build flags, sets `SOURCE_DATE_EPOCH=1760997684`, builds serially and
|
||||
emits exactly:
|
||||
|
||||
- `pcre2.mjs` and `pcre2.wasm`;
|
||||
- deterministic `engine-metadata.json`;
|
||||
- the exact upstream `LICENSE.txt`;
|
||||
- `SHA256SUMS`.
|
||||
|
||||
The verifier checks the closed file set, metadata, source bridge hashes and all
|
||||
checksums. It validates and instantiates WebAssembly, checks ABI/engine/config
|
||||
identity, then executes native Unicode compile, bounded match and bounded
|
||||
substitution smoke cases plus normal and cap-stopped automatic-callout traces.
|
||||
|
||||
The immutable signed-tag object is pinned. OpenPGP trust validation remains a
|
||||
release-operator step because the offline builder does not install or trust a
|
||||
key.
|
||||
|
||||
## Runtime boundary
|
||||
|
||||
ABI version 3 has no retained native handles. Each call compiles, obtains
|
||||
capture names, matches/substitutes or traces and releases its code, match data
|
||||
and match context before returning. The caller supplies fixed-capacity
|
||||
result/name/output/event/mark buffers. Hard maxima are 1 MiB pattern, 16 MiB
|
||||
subject, 1,000 captures, 10,000 matches, 100,000 capture rows, 50,000 trace
|
||||
events, 10 MiB serialized trace data and 128 MiB PCRE2 heap limit; the
|
||||
workbench uses stricter pattern and replacement input limits where applicable.
|
||||
|
||||
PCRE2 always runs with UTF and UCP. Native UTF-8 byte offsets are retained in
|
||||
DTOs and converted only at valid code-point boundaries to half-open editor
|
||||
UTF-16 ranges. Lone UTF-16 surrogates are rejected before encoding.
|
||||
|
||||
Normal execution lives only in `pcre2.worker`. Instrumented
|
||||
`PCRE2_AUTO_CALLOUT` collection is a separate ABI operation loaded only by
|
||||
`pcre2-trace.worker`; normal match, replacement, tests, corpus and benchmarks
|
||||
never call it. Each supervisor terminates its worker on timeout, crash,
|
||||
cancellation or supersession and lazily creates a clean generation for the
|
||||
next request.
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
# Execution traces
|
||||
|
||||
Version 0.1.0 exposes no execution trace.
|
||||
Browser ECMAScript APIs expose compilation, match, capture and replacement
|
||||
results, but no V8, SpiderMonkey or JavaScriptCore execution trace. Regex Tools
|
||||
does not relabel structural explanations, static findings or timing
|
||||
observations as an ECMAScript engine trace.
|
||||
|
||||
Browser ECMAScript APIs return compilation, match, capture and replacement
|
||||
results but not the runtime's internal backtracking events. Regex Tools does
|
||||
not label structural explanations, static hints or adjacent-result inference
|
||||
as an actual engine trace.
|
||||
PCRE2 10.47 has a separate actual automatic-callout vertical. ABI version 3
|
||||
compiles the requested pattern with `PCRE2_AUTO_CALLOUT`, registers an
|
||||
application callback, and copies only:
|
||||
|
||||
The next PCRE2 slice will use actual automatic/explicit callout events from an
|
||||
application-owned bridge. Event count, serialized bytes, match/depth/heap
|
||||
limits and wall-clock termination must all be enforced. Classifications such
|
||||
as “apparent backtrack” will remain visibly derived from adjacent events.
|
||||
- callout number;
|
||||
- pattern byte position and next-item byte length;
|
||||
- subject byte position;
|
||||
- capture-top and capture-last numbers;
|
||||
- bounded current mark text.
|
||||
|
||||
These fields have `reported` provenance. Pattern and subject byte boundaries
|
||||
are validated and normalized to exact editor UTF-16 positions while the native
|
||||
values remain visible.
|
||||
|
||||
Collection runs in `pcre2-trace.worker`, not the normal execution worker. It
|
||||
uses the selected PCRE2 match, depth and heap limits plus the supervisor wall
|
||||
clock. The callback retains only complete 32-byte records and mark slices under
|
||||
both a 50,000-event and 10 MiB serialized-data hard cap. At a cap it returns
|
||||
PCRE2’s reserved `PCRE2_ERROR_CALLOUT`, abandoning the native match; the result
|
||||
retains that native status, the last complete event and an explicit truncation
|
||||
flag. Timeout, cancellation or crash discards the complete worker.
|
||||
|
||||
The viewer derives “forward”, “same position” and “apparent backtrack” solely
|
||||
from the difference between adjacent reported subject positions. Each label
|
||||
has `derived` provenance. This is useful navigation, not a claim that the
|
||||
stream reports every internal action. PCRE2 start optimizations can also
|
||||
conclude some requests without any callout.
|
||||
|
||||
Tracing represents one exact `pcre2_match()` invocation. The application-level
|
||||
`g` iteration flag is not applied, and that fact is reported. Normal match,
|
||||
replacement, tests, corpus, comparisons and benchmarks never use the
|
||||
instrumented trace path.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Explanation model
|
||||
|
||||
The community provider converts regexpp nodes into a stable application AST
|
||||
inside the syntax worker. Nodes carry deterministic IDs, UTF-16 ranges, raw
|
||||
source, capture metadata, quantifier bounds, assertion kind, support status,
|
||||
provider identity and provenance.
|
||||
The ECMAScript community provider converts regexpp nodes into a stable
|
||||
application AST inside the syntax worker. Nodes carry deterministic IDs,
|
||||
UTF-16 ranges, raw source, capture metadata, quantifier bounds, assertion kind,
|
||||
support status, provider identity and provenance.
|
||||
|
||||
Explanations are original deterministic text selected by node type. Nullable
|
||||
and minimum/maximum consumed-length properties are derived recursively where
|
||||
@@ -15,6 +15,13 @@ regexpp 4.12.2 does not expose tolerant recovery. A malformed pattern therefore
|
||||
gets the provider's exact diagnostic plus an application-owned error node. No
|
||||
partial regexpp AST is claimed.
|
||||
|
||||
The PCRE2 provider currently emits a deliberately partial lexical tree for
|
||||
common capture forms. It does not infer branch-reset numbering or claim full
|
||||
nesting; authoritative compilation, capture counts, names and result ranges
|
||||
come from PCRE2 itself.
|
||||
|
||||
The explanation tree is structural. It is never called an actual execution
|
||||
trace. Selecting a node selects and scrolls its exact editor range; selecting
|
||||
pattern text chooses the smallest enclosing node.
|
||||
pattern text chooses the smallest enclosing node. PCRE2’s separate trace viewer
|
||||
contains actual bounded automatic callouts; its movement labels remain a
|
||||
distinct derived layer and are never copied into the structural explanation.
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
# Flavour support
|
||||
|
||||
| Flavour | Syntax | Execution | Replacement | Captures | Trace | Native offsets |
|
||||
| ------------ | ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | ----------- | ------------------- |
|
||||
| ECMAScript | regexpp 4.12.2 grammar; explanations partial/feature-matrix tested | Current browser `RegExp`; feature-matrix tested | Native matches; bounded ECMAScript `GetSubstitution` | Named/numbered; final repeated capture; no history | Unavailable | UTF-16 |
|
||||
| PCRE2 | Unavailable in release | Technical spike only; not shipped | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
||||
| PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable |
|
||||
| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points |
|
||||
| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
||||
| Rust `regex` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
||||
| .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Planned UTF-16 |
|
||||
| Java | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-16 |
|
||||
| Flavour | Syntax | Execution | Replacement | Captures | Trace | Analysis | Generated cases | Native offsets |
|
||||
| ------------ | ------------------------------------------------------------ | ------------------------------------------------------ | ---------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------- | ------------------- |
|
||||
| ECMAScript | regexpp 4.12.2; explanations partial/feature-matrix tested | Current browser `RegExp`; conformance tested | Bounded ECMAScript `GetSubstitution` | Named/numbered; final repeated capture; no history | Unavailable | Experimental static heuristics + bounded native probes | Bounded normalized-AST sampling; actual-engine verified | UTF-16 |
|
||||
| PCRE2 10.47 | Application lexical provider partial; compiler authoritative | Pinned 8-bit WASM; bounded/killable/conformance tested | Native bounded `pcre2_substitute()` loop | Native named/numbered records; no capture history | Bounded reported automatic callouts; derived movement labels | Unavailable; ECMAScript heuristics are never applied | Unavailable; structural syntax provider is partial | UTF-8 bytes |
|
||||
| PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable |
|
||||
| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points |
|
||||
| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
||||
| Rust `regex` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
||||
| .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Unavailable | Unavailable | Planned UTF-16 |
|
||||
| Java | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-16 |
|
||||
|
||||
ECMAScript tests cover `g`, `y`, `u`, internal `d`, zero-length iteration,
|
||||
named groups, unmatched/empty/repeated groups, lookbehind, backreferences,
|
||||
Unicode properties, astral input, replacement and result truncation.
|
||||
ECMAScript tests cover its complete exposed flag matrix, internal indices,
|
||||
zero-length iteration, captures, lookbehind, backreferences, Unicode properties
|
||||
and bounded replacement.
|
||||
|
||||
Actual browser identity is displayed because ECMAScript behavior can evolve
|
||||
with the runtime. Syntax acceptance and engine compilation are separate;
|
||||
neither silently rewrites the pattern.
|
||||
PCRE2 tests cover `g`, `i`, `m`, `s`, `x`, `U`, `J`, mandatory UTF/UCP,
|
||||
branch-reset groups, recursion, `\K`, duplicate names, native substitution,
|
||||
astral byte/UTF-16 ranges, zero-length iteration, compile errors, match limits
|
||||
and result/output truncation. Trace tests cover reported automatic callouts,
|
||||
bounded mark copying, native stop at the complete-event cap, compile ranges,
|
||||
worker loading and browser rendering.
|
||||
|
||||
ECMAScript static findings and generated-input timing observations are
|
||||
advisory. They describe only the selected ECMAScript 2025 pattern, browser
|
||||
runtime and bounded inputs; they are not proofs of safety or general
|
||||
complexity.
|
||||
|
||||
ECMAScript generated-case version 1 uses only a complete accepted normalized
|
||||
AST, an explicit deterministic seed and bounded sampling strategies. Every
|
||||
retained positive or negative label is confirmed by the actual selected
|
||||
browser engine. PCRE2 generation is unavailable because its current lexical
|
||||
provider is intentionally partial; ECMAScript behavior is never relabelled as
|
||||
PCRE2.
|
||||
|
||||
The PCRE2 explanation provider deliberately does not invent full structural
|
||||
coverage. In particular, branch-reset numbering is omitted from the syntax tree
|
||||
while actual capture numbers and names still come from PCRE2 results.
|
||||
|
||||
ECMAScript and PCRE2 support exact-request comparison for syntax acceptance,
|
||||
engine compilation, matches, captures and engine-native replacement. Results
|
||||
align by normalized UTF-16 ranges while retaining each native offset model.
|
||||
Timeout, cancellation, rejection and truncation are never presented as
|
||||
equivalent results.
|
||||
|
||||
Pattern formatting is available only for an exact accepted ECMAScript regexpp
|
||||
snapshot. It canonicalizes literal slashes and raw control/line-separator
|
||||
representations, then requires paired actual-engine and applicable exact-test
|
||||
validation before apply. PCRE2 formatting is unavailable because the current
|
||||
provider is deliberately partial; its syntax is never treated as ECMAScript.
|
||||
|
||||
PCRE2 is the only current code-generation target. The reviewed C17 generator is
|
||||
pinned to the PCRE2 10.47 8-bit API; other language generators remain
|
||||
unavailable until they pass equivalent escaping and named-toolchain gates.
|
||||
|
||||
57
docs/FORMATTING.md
Normal file
57
docs/FORMATTING.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Pattern formatting
|
||||
|
||||
Regex Tools includes a deliberately narrow, local ECMAScript formatter. It
|
||||
canonicalizes representations that can be changed without inventing a visual
|
||||
layout:
|
||||
|
||||
- an unescaped literal `/` becomes `\/`, making the pattern safe to copy into
|
||||
an ECMAScript regex literal;
|
||||
- raw NUL and control code units become fixed-width `\xNN` or their canonical
|
||||
short escape (`\t`, `\n`, `\v`, `\f`, `\r`);
|
||||
- raw U+2028 and U+2029 line separators become `\u2028` and `\u2029`;
|
||||
- legacy escaped raw controls are replaced as one parser-reported literal
|
||||
token, so the formatter does not introduce an extra backslash.
|
||||
|
||||
Existing explicit escape spellings, groups, alternatives, quantifiers, inline
|
||||
flags and literal whitespace are otherwise preserved byte for byte. ECMAScript
|
||||
regex whitespace is normally significant, so the tool does not insert
|
||||
indentation, line breaks or comments and does not claim to be a structural
|
||||
pretty-printer. The transform is idempotent.
|
||||
|
||||
## Eligibility
|
||||
|
||||
Formatting requires the exact current, accepted, non-recovered syntax snapshot
|
||||
from the bundled `@eslint-community/regexpp` provider. Transformations are
|
||||
derived from the provider's literal-token ranges rather than from a heuristic
|
||||
text scan. A stale parse, a malformed pattern, a partial provider, PCRE2 or any
|
||||
other flavour is reported as unavailable and is never silently rewritten.
|
||||
|
||||
## Mandatory validation before apply
|
||||
|
||||
The preview cannot be applied directly. Regex Tools first:
|
||||
|
||||
1. reparses source and candidate independently and checks capture numbering,
|
||||
names, repetition and parent-capture shape;
|
||||
2. compiles and runs the source and candidate in separate actual ECMAScript
|
||||
engine workers against the current subject and replacement;
|
||||
3. compares exact normalized match, capture and replacement output, including
|
||||
the engine identity;
|
||||
4. reruns every enabled unit test whose flavour, version, pattern, flags and
|
||||
engine options exactly match the active source snapshot, and compares both
|
||||
semantic output and assertion outcome.
|
||||
|
||||
A compile rejection, one-sided timeout, crash, worker error, cancellation,
|
||||
truncated result, engine-identity change, incomplete test suite or semantic
|
||||
difference blocks application. Matching fixed timeouts are comparable only for
|
||||
an exact unit-test pair; two timeouts on the current interactive snapshot are
|
||||
inconclusive. If no unit test is applicable, the UI says so and still requires
|
||||
the current subject/replacement gate.
|
||||
|
||||
Validation is bounded to 1,000 tests, the normal worker/input limits and a
|
||||
60-second aggregate test wall budget. A test is not started unless its complete
|
||||
configured timeout still fits that budget. The user must explicitly confirm
|
||||
the exact validated candidate before the Apply button is enabled.
|
||||
|
||||
Passing these gates is strong, reproducible evidence for the current snapshot
|
||||
and exact tests. It is not a mathematical proof of equivalence for every
|
||||
possible subject.
|
||||
135
docs/GENERATED_CASES.md
Normal file
135
docs/GENERATED_CASES.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Generated test cases
|
||||
|
||||
Regex Tools generator version 1 creates deterministic candidate subjects from
|
||||
the application-owned normalized AST and then verifies every candidate through
|
||||
the selected actual engine adapter. Generation is local-only and bounded. It is
|
||||
sampling, not proof of language coverage, equivalence or safety.
|
||||
|
||||
## Supported scope
|
||||
|
||||
Version 1 is scoped to an accepted ECMAScript 2025 normalized AST from the
|
||||
bundled regexpp-based provider. It requests:
|
||||
|
||||
- a shortest structural candidate using the shortest visible alternative and
|
||||
each quantifier minimum;
|
||||
- each structurally visible alternative;
|
||||
- quantifier minimum, adjacent and bounded upper values;
|
||||
- optional-absent and optional-present values;
|
||||
- bounded character-class, dot and Unicode-property representatives;
|
||||
- newline and non-boundary context around anchors;
|
||||
- deletion, replacement and boundary mutations as likely near misses;
|
||||
- deterministic random alternative, quantifier and class choices.
|
||||
|
||||
The coverage report says `covered`, `partial`, `unsupported` or
|
||||
`not applicable` for each category. “Covered” means that the documented bounded
|
||||
sampling strategy was requested. It does not mean every string in the regular
|
||||
language was enumerated.
|
||||
|
||||
PCRE2 generation is unavailable in version 1. Its actual engine is complete for
|
||||
the advertised execution operations, but its current structural syntax
|
||||
provider is intentionally partial. Regex Tools does not feed PCRE2 syntax to
|
||||
the ECMAScript generator or relabel ECMAScript behavior.
|
||||
|
||||
## Unsupported and partial constructs
|
||||
|
||||
The report includes exact normalized source ranges and a reason for every
|
||||
recognized gap, capped at 250 rendered entries. Version 1 does not solve:
|
||||
|
||||
- capture-dependent backreferences;
|
||||
- lookaround or word-boundary constraints symbolically;
|
||||
- recursion, subroutine calls or conditional execution;
|
||||
- branch-reset and atomic grouping;
|
||||
- engine control verbs or callouts;
|
||||
- complex Unicode-set algebra and properties of strings;
|
||||
- recovered or provider-unsupported syntax.
|
||||
|
||||
Surrounding candidates may still happen to satisfy a lookaround or boundary.
|
||||
They are retained only when the actual selected engine confirms the requested
|
||||
match outcome. That confirmation does not upgrade the generator’s structural
|
||||
coverage claim.
|
||||
|
||||
## Determinism and seed
|
||||
|
||||
The seed is explicit text of 1–128 UTF-16 units. Generator version 1 hashes its
|
||||
UTF-16 code units with a documented stable 32-bit FNV-1a step and drives an
|
||||
application-owned 32-bit deterministic sequence. It never calls
|
||||
`Math.random()`.
|
||||
|
||||
The same generator version, normalized AST, flags, settings and seed produce
|
||||
the same candidate order and candidate identifiers. Verification timing is
|
||||
environment-dependent, and strict wall-time or engine-timeout bounds can
|
||||
therefore stop two runs at different points. The UI records both the original
|
||||
seed and its eight-digit hash. Generated unit tests retain:
|
||||
|
||||
- generator id and version;
|
||||
- the original seed;
|
||||
- candidate id;
|
||||
- intended positive or negative outcome.
|
||||
|
||||
That provenance survives project and test-suite JSON export when subjects are
|
||||
included. Editing a generated test turns it into a user-authored test and
|
||||
removes the generation provenance.
|
||||
|
||||
## Actual-engine verification
|
||||
|
||||
Candidate synthesis runs in a dedicated killable worker. The verifier then
|
||||
sends each exact candidate, pattern, flavour version, flags, engine options and
|
||||
scan mode through `EngineSupervisor`, which selects the registered real engine
|
||||
worker.
|
||||
|
||||
- An intended positive is labelled `should-match` only after the engine returns
|
||||
at least one match.
|
||||
- An intended negative is labelled `should-not-match` only after the engine
|
||||
returns no match.
|
||||
- A candidate with the opposite result is discarded and retained only as a
|
||||
bounded advanced diagnostic.
|
||||
- Engine compile rejection, timeout, worker crash, cancellation and ordinary
|
||||
execution error remain distinct outcomes.
|
||||
- Cancellation terminates both candidate-synthesis and active engine workers.
|
||||
- Configuration changes invalidate results immediately; old cases are never
|
||||
displayed against a new pattern or engine configuration.
|
||||
|
||||
The engine name, version, adapter, runtime and native offset unit shown with the
|
||||
result come from the actual execution result, not static marketing metadata.
|
||||
|
||||
## Bounds
|
||||
|
||||
Defaults:
|
||||
|
||||
| Bound | Default |
|
||||
| -------------------------------------- | ------------------------: |
|
||||
| Retained verified cases | 48 |
|
||||
| Candidate attempts | 192 |
|
||||
| Seeded random variants | 16 |
|
||||
| Per-case engine timeout | 500 ms |
|
||||
| Aggregate wall time | 15 s |
|
||||
| Bytes per candidate subject | 64 KiB |
|
||||
| Aggregate candidate subject bytes | 1 MiB |
|
||||
| Synthesized repetitions per quantifier | 32 |
|
||||
| Normalized AST traversal | 20,000 nodes / 512 levels |
|
||||
|
||||
Hard caps:
|
||||
|
||||
| Bound | Hard cap |
|
||||
| -------------------------------------- | -------: |
|
||||
| Retained verified cases | 1,000 |
|
||||
| Candidate attempts | 4,000 |
|
||||
| Per-case engine timeout | 10 s |
|
||||
| Aggregate wall time | 30 s |
|
||||
| Bytes per candidate subject | 16 MiB |
|
||||
| Aggregate candidate subject bytes | 16 MiB |
|
||||
| Synthesized repetitions per quantifier | 1,024 |
|
||||
| Advanced discard diagnostics | 250 |
|
||||
|
||||
UTF-8 sizes are checked before a candidate is retained for verification.
|
||||
Quantifier expansion is preflighted before `String.prototype.repeat` allocates
|
||||
the output. Reaching a case, attempt, byte, AST, repetition or wall-time bound
|
||||
is visible in the result; it is never reported as complete coverage.
|
||||
|
||||
## Privacy and persistence
|
||||
|
||||
No pattern, candidate, verified subject, diagnostic or seed is uploaded.
|
||||
Results and settings are ephemeral until the user explicitly adds cases to the
|
||||
unit-test suite or downloads the verified JSON artifact. Test-suite export
|
||||
omits subjects by default because generated subjects may still encode literal
|
||||
pattern content. Enabling subject export is an explicit user choice.
|
||||
71
docs/MINIMIZATION.md
Normal file
71
docs/MINIMIZATION.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Subject minimization
|
||||
|
||||
The **Minimize** workspace reduces one failing subject locally against the
|
||||
selected real ECMAScript or PCRE2 worker. It can preserve:
|
||||
|
||||
- the exact failure class of any supported saved unit-test assertion;
|
||||
- an identified capture's wrong participation, value or UTF-16 range;
|
||||
- an engine worker timeout at one fixed deadline;
|
||||
- an ECMAScript/PCRE2 semantic mismatch with the same mismatch-kind set; or
|
||||
- the same one-sided comparison timeout while the other engine completes with
|
||||
authoritative, untruncated output.
|
||||
|
||||
The fixed pattern, ordered flags, options, replacement and engine versions form
|
||||
the target identity. Syntax is parsed once to obtain capture metadata. Every
|
||||
accepted subject candidate is then verified through the selected execution
|
||||
worker; the reducer does not contain a substitute regex implementation.
|
||||
|
||||
## Deterministic transforms
|
||||
|
||||
Subjects must be well-formed Unicode. The reducer never splits a surrogate
|
||||
pair and rejects input containing a lone surrogate.
|
||||
|
||||
Reduction order is stable:
|
||||
|
||||
1. contiguous ddmin chunk deletion, left to right;
|
||||
2. single Unicode-scalar deletion, left to right; then
|
||||
3. lower-rank replacement by `a`, `0`, space, newline and `!`, in that order.
|
||||
|
||||
The last two passes restart after every accepted change and continue to a fixed
|
||||
point. A complete pass with no accepted candidate establishes only **local
|
||||
minimality under those transforms**. It is not a proof of the shortest,
|
||||
globally minimal or semantically simplest reproducer. Evaluation or wall-budget
|
||||
exhaustion, an inconclusive candidate, cancellation and worker failure always
|
||||
return a result without a minimality claim.
|
||||
|
||||
## Exact predicates
|
||||
|
||||
A baseline run must reproduce the requested failure before reduction starts.
|
||||
Unit-test failures retain their assertion class; a wrong capture value cannot
|
||||
turn into a missing capture, and a semantic comparison retains the exact sorted
|
||||
set of baseline mismatch kinds. Rejected patterns and truncated match,
|
||||
capture or replacement data are not accepted as semantic evidence.
|
||||
|
||||
Timeout is a worker deadline outcome, never a no-match. An explicit timeout
|
||||
predicate accepts only `WorkerRequestError("timeout")` at the configured fixed
|
||||
deadline. Comparison timeout reduction additionally requires the same flavour
|
||||
to time out and the peer flavour to complete with the same engine identity and
|
||||
complete data. Crash, unreadable worker response, ordinary worker error and
|
||||
cancellation have separate statuses. Crash minimization is unsupported and a
|
||||
crash never satisfies a timeout predicate.
|
||||
|
||||
For a `must-time-out` unit-test assertion, an ordinary completion is the test
|
||||
failure being minimized; a timeout makes that assertion pass. The separate
|
||||
**Exact engine timeout** target is used to minimize an observed timeout.
|
||||
|
||||
## Bounds and progress
|
||||
|
||||
- Subject: 1 MiB UTF-8, separate from the larger interactive editor cap.
|
||||
- Candidate evaluations: at most 2,000, including the baseline.
|
||||
- Aggregate syntax-setup and reduction wall time: at most 60 seconds.
|
||||
- Candidate worker deadline: fixed for the complete run, at most 10 seconds.
|
||||
- Retained accepted-transform history: 200 entries; totals remain exact.
|
||||
|
||||
A candidate starts only when its full fixed deadline fits inside the remaining
|
||||
aggregate wall budget. This prevents the aggregate deadline from being
|
||||
misreported as an engine timeout. Progress exposes phase, evaluation and wall
|
||||
budgets, accepted reductions, current scalar/byte sizes and the latest
|
||||
observation. Cancellation terminates syntax and execution workers.
|
||||
|
||||
The minimized subject can be copied or deliberately applied to the main editor.
|
||||
Inputs and results stay in memory and are neither uploaded nor persisted.
|
||||
@@ -7,10 +7,13 @@ community
|
||||
```
|
||||
|
||||
It uses only reviewed open-source dependencies and builds without private
|
||||
registry credentials. Version 0.1.0 parses ECMAScript with regexpp 4.12.2.
|
||||
registry credentials. ECMAScript uses regexpp 4.12.2. PCRE2 uses an
|
||||
application-owned lexical provider for partial explanations and the actual
|
||||
bundled PCRE2 compiler for authoritative acceptance.
|
||||
|
||||
The product deliberately does not implement or reference an `@r101/parser`
|
||||
profile. No commercial package alias, import, tarball, credential, licence key
|
||||
or private CI path exists. Additional flavour syntax will be implemented
|
||||
incrementally as open-source providers behind the same application-owned
|
||||
interface.
|
||||
interface. Partial coverage is exposed as metadata and never presented as a
|
||||
complete parser.
|
||||
|
||||
66
docs/PCRE2_NEXT_SLICES.md
Normal file
66
docs/PCRE2_NEXT_SLICES.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# PCRE2 advanced slices
|
||||
|
||||
This document records the reviewed boundaries for advanced PCRE2 work. A
|
||||
feature is exposed only after its complete runtime and UI path passes the
|
||||
stated gates.
|
||||
|
||||
## Automatic-callout trace — implemented
|
||||
|
||||
ABI version 3 and the dedicated trace worker now provide:
|
||||
|
||||
- a new ABI operation separate from normal matching, compiled with
|
||||
`PCRE2_AUTO_CALLOUT`;
|
||||
- a fixed copied event record containing only reported callout number, pattern
|
||||
byte position, next-item length, subject byte position, capture top/last and
|
||||
bounded mark text;
|
||||
- caller-supplied event and text buffers capped by both
|
||||
`maximumTraceEvents` and `maximumTraceBytes`;
|
||||
- explicit `eventsTruncated`, native status and last-complete-event fields;
|
||||
- the existing match/depth/heap limits and supervisor timeout;
|
||||
- cleanup identical to normal ABI calls.
|
||||
|
||||
Tracing runs in its own worker and never replaces the normal result used by
|
||||
Match, Replace, Tests, Corpus or Analysis. “Forward”, “same position” and
|
||||
“apparent backtrack” are derived from adjacent reported positions only and
|
||||
carry derived provenance. The viewer explicitly states that a callout stream
|
||||
is not a complete account of every internal engine action.
|
||||
|
||||
## ECMAScript versus PCRE2 comparison — implemented
|
||||
|
||||
The comparison orchestrator issues two explicit requests with
|
||||
`Promise.allSettled` through independently killable flavour workers. It does
|
||||
not translate flags, patterns or replacement syntax.
|
||||
|
||||
The comparison DTO retains:
|
||||
|
||||
- each exact request identity, engine identity and independently complete or
|
||||
timed-out result;
|
||||
- match/capture differences aligned by editor UTF-16 ranges, while retaining
|
||||
each native offset model;
|
||||
- explicit “not comparable” states for syntax/compile failure, truncation,
|
||||
timeout, cancellation, worker failure or flavour-only constructs;
|
||||
- a shared wall-clock cancellation action that terminates both workers.
|
||||
|
||||
The orchestrator remains outside `EngineSupervisor`, preserving the
|
||||
single-engine lifecycle boundary. Shared patterns are sent unchanged and
|
||||
explicit per-flavour variants remain user-owned. Agreement is labelled only as
|
||||
evidence for the current subject.
|
||||
|
||||
## PCRE2 C code generation — implemented
|
||||
|
||||
The first reviewed target consumes a validated request snapshot and uses an
|
||||
application-owned C17 template. It:
|
||||
|
||||
- identifies exact PCRE2 10.47, 8-bit, UTF/UCP semantics and selected flags;
|
||||
- emits pattern, subject and replacement independently as exact UTF-8 byte
|
||||
arrays;
|
||||
- includes match/depth/heap limits and error handling in generated C examples;
|
||||
- states when a target binding cannot express an application-level `g` flag or
|
||||
a Regex Tools output/result cap directly;
|
||||
- ships deterministic golden match/replacement fixtures that compile and
|
||||
execute against official PCRE2 10.47 before the target is advertised.
|
||||
|
||||
No generated snippet may be used as the implementation of the browser runtime.
|
||||
Other target languages remain unavailable until their own escaping and
|
||||
toolchain gates pass. See
|
||||
[`COMPARISON_AND_CODEGEN.md`](COMPARISON_AND_CODEGEN.md).
|
||||
@@ -10,6 +10,18 @@ browser, engine version, pattern and subject. The UI therefore reports observed
|
||||
elapsed time and exact runtime identity but makes no universal throughput or
|
||||
safety claim.
|
||||
|
||||
PCRE2 cold execution includes loading and instantiating the self-hosted
|
||||
WebAssembly module inside its worker; warm requests reuse that instance.
|
||||
Every request still compiles and frees its pattern so no unbounded compiled-code
|
||||
cache can accumulate. Native match-step, depth and heap limits complement the
|
||||
supervisor wall clock, and cancellation discards the complete worker heap.
|
||||
|
||||
Automatic-callout tracing is deliberately excluded from every benchmark. It
|
||||
compiles an independently instrumented PCRE2 pattern in a separate worker and
|
||||
can materially change runtime work. The trace path retains at most 50,000
|
||||
complete 32-byte event records and 10 MiB total serialized event/mark bytes;
|
||||
the viewer renders at most 2,000 retained events.
|
||||
|
||||
The production bundle separates syntax and execution workers. Interactive
|
||||
syntax/extraction trees and editor decoration sets render at most 2,000 items
|
||||
each and report both the rendered and actual totals. These are presentation
|
||||
@@ -62,5 +74,57 @@ representation against a byte budget before materializing or writing it.
|
||||
IndexedDB schema version 2 indexes `updatedAt`; latest-project loading opens one
|
||||
descending cursor and validates only that record instead of calling `getAll()`.
|
||||
|
||||
Formal cold/warm benchmarks, p95 statistics and growth charts are deferred to
|
||||
the analysis milestone and will run in killable workers.
|
||||
Corpus runs accept at most 256 documents, 16 MiB per document and 256 MiB of
|
||||
aggregate UTF-8 input. Jobs run sequentially so only one engine request is
|
||||
active at a time. A batch retains at most 100,000 matches and 64 MiB of applied
|
||||
output, and stops after five minutes of aggregate wall time. Each document also
|
||||
uses the selected manual timeout. Progress is reported after every document;
|
||||
cancellation kills the current worker and labels all remaining documents.
|
||||
Result tables contain summaries, not match/capture objects. ZIP export is
|
||||
available only for an all-exact apply batch and is created asynchronously.
|
||||
|
||||
Independent-line mode preflights at most 100,000 logical lines and processes
|
||||
each as an isolated engine subject while preserving line separators for exact
|
||||
apply output. Per document, corpus extraction retains at most 32 group summaries
|
||||
(including the full match), three 160-UTF-16-unit samples per group and 100
|
||||
unique diagnostics. Output previews show 2,000 units. Content-free summary
|
||||
exports are capped at 4 MiB; the applied archive is capped at 68 MiB in addition
|
||||
to the 64 MiB uncompressed-output budget.
|
||||
|
||||
ECMAScript analysis reports cold compilation separately from warm native
|
||||
execution. The default benchmark uses three warm-up and 15 measured samples;
|
||||
user caps are 100 warm-ups and 1,000 samples. It reports median and
|
||||
nearest-rank p95 values plus separate timing and outcome/growth plots with the
|
||||
exact runtime identity. Each sample defaults to a 2-second killable-worker
|
||||
limit (10-second cap), and the aggregate analysis wall time is 30 seconds.
|
||||
|
||||
Growth probes retain at most 24 steps, generate at most 1 MiB by default under
|
||||
the central 16 MiB subject cap, and reject more than 10,000,000 repetitions
|
||||
before string allocation. Replacement timing is skipped when match collection
|
||||
is truncated or the estimated output exceeds 8 Mi UTF-16 units. Results are
|
||||
observations for the chosen runtime and generated inputs, never a general
|
||||
complexity proof. See [`ANALYSIS.md`](ANALYSIS.md).
|
||||
|
||||
Generated-case synthesis defaults to 48 retained cases from at most 192
|
||||
candidates, 16 seeded-random variants, 64 KiB per candidate, 1 MiB aggregate
|
||||
candidate bytes and 15 seconds of aggregate wall time. Every candidate then
|
||||
uses a separate selected-engine request with a 500 ms default timeout.
|
||||
Generation preflights UTF-8 size and repetition bounds, caps normalized-AST
|
||||
traversal at 20,000 nodes / 512 levels and retains only bounded diagnostics.
|
||||
Hard caps are documented in [`GENERATED_CASES.md`](GENERATED_CASES.md).
|
||||
|
||||
Subject minimization accepts at most 1 MiB UTF-8, performs at most 2,000 real
|
||||
engine candidate evaluations and stops after at most 60 seconds including
|
||||
syntax setup. One fixed worker timeout of at most 10 seconds applies to every
|
||||
candidate. A candidate starts only when that complete deadline fits in the
|
||||
remaining aggregate budget. Accepted-transform history retains 200 entries;
|
||||
evaluation and accepted totals remain exact. See
|
||||
[`MINIMIZATION.md`](MINIMIZATION.md).
|
||||
|
||||
Formatter validation uses two syntax supervisors and two actual-engine
|
||||
supervisors so source/candidate outcomes cannot share compiled state. The
|
||||
current subject/replacement snapshot always runs; at most 1,000 enabled tests
|
||||
with the exact active identity are replayed. Test validation has a 60-second
|
||||
aggregate wall budget, and a test starts only when its complete configured
|
||||
worker timeout still fits. Preview details retain at most 10,000 transforms and
|
||||
the UI renders the first 200. See [`FORMATTING.md`](FORMATTING.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Portal requirements
|
||||
|
||||
Regex Tools 0.1.0 is a static nested-path-safe application.
|
||||
Regex Tools is a static nested-path-safe application.
|
||||
|
||||
Required delivery behavior:
|
||||
|
||||
@@ -9,11 +9,17 @@ Required delivery behavior:
|
||||
revalidation/no-cache resources.
|
||||
- Hashed Vite assets may use immutable long-term caching.
|
||||
- CSP must allow `worker-src 'self' blob:`.
|
||||
- No WebAssembly MIME rule or `wasm-unsafe-eval` is required by v0.1.0.
|
||||
- `engines/pcre2/pcre2.wasm` must use `application/wasm`.
|
||||
- `script-src` must permit self-hosted WebAssembly compilation, normally with
|
||||
`'wasm-unsafe-eval'`; broad `'unsafe-eval'` is neither needed nor recommended.
|
||||
- `engines/pcre2/pcre2.mjs`, metadata, checksums, licence and WASM should use
|
||||
revalidation or a release-scoped immutable cache. They are not
|
||||
content-hashed filenames and must never drift under one release identity.
|
||||
|
||||
The Portal must consume the immutable release ZIP, verify its SHA-256, verify
|
||||
manifest ID `de.add-ideas.regex-tools` and version `0.1.0`, and mount it at a
|
||||
manifest ID `de.add-ideas.regex-tools` and version `0.2.0`, and mount it at a
|
||||
relative target such as `apps/regex/`.
|
||||
|
||||
When PCRE2 ships later, the smallest additional change will be
|
||||
`application/wasm` delivery and `script-src 'self' 'wasm-unsafe-eval'`.
|
||||
The package, manifest, tag, artifact URL and checksum must all use the v0.2.0
|
||||
identity. The historical v0.1.0 artifact coordinates remain immutable and must
|
||||
not be overwritten or reused.
|
||||
|
||||
@@ -8,7 +8,7 @@ Inspection date: 2026-07-24.
|
||||
| Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. |
|
||||
| Toolbox Portal | `a9c31c8986c40a0097966318e925083302e91e13` / 0.5.0 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. |
|
||||
| regexpp | 4.12.2; npm integrity `sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==` | MIT | Pinned ECMAScript syntax provider. Provider AST is normalized in a worker. |
|
||||
| PCRE2 | signed tag 10.47; commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429` | BSD-3-Clause WITH PCRE2-exception | External technical spike only; not shipped or copied. |
|
||||
| PCRE2 | signed tag 10.47; commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429` | BSD-3-Clause WITH PCRE2-exception | Pinned offline build; generated verified WebAssembly pack is shipped, source is not vendored. |
|
||||
| ECMAScript specification | <https://tc39.es/ecma262/>; living specification inspected 2026-07-24 | ECMA terms | Semantics reference; no prose copied. |
|
||||
| regex101 | <https://regex101.com/> and public parser documentation | Product/commercial terms | Product reference only. No source, branding, explanations, reference prose or visual design copied. |
|
||||
| `recheck` | 4.5.0 | MIT | Metadata inspected; not installed or shipped. |
|
||||
@@ -17,5 +17,5 @@ Inspection date: 2026-07-24.
|
||||
| RegexLib | Website inspected | Redistribution licence not established | No fixture copied. |
|
||||
| RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. |
|
||||
|
||||
All v0.1.0 conformance cases are project-authored. See
|
||||
All v0.1.0 and v0.2.0 conformance cases are project-authored. See
|
||||
`tests/fixtures/README.md`.
|
||||
|
||||
@@ -18,6 +18,9 @@ on publication failure, replaces an existing exact version only with
|
||||
`--force`, and produces:
|
||||
|
||||
```text
|
||||
release/regex-tools-0.1.0.zip
|
||||
release/regex-tools-0.1.0.zip.sha256
|
||||
release/regex-tools-0.2.0.zip
|
||||
release/regex-tools-0.2.0.zip.sha256
|
||||
```
|
||||
|
||||
The existing `release/regex-tools-0.1.0.zip` and matching sidecar are historical
|
||||
immutable artifacts. Creating v0.2.0 must not replace, rename or remove them.
|
||||
|
||||
@@ -9,6 +9,16 @@ untrusted.
|
||||
- A terminated worker is never reused.
|
||||
- Pattern, subject, replacement/list template, match, capture and output sizes
|
||||
are bounded.
|
||||
- PCRE2 adds native match-step, depth and heap caps. Its C ABI returns only
|
||||
caller-owned bounded records and releases code, match data and match context
|
||||
on every exit path.
|
||||
- PCRE2 automatic-callout tracing is a separate ABI and worker path. It copies
|
||||
only complete fixed records and bounded mark text, stops matching at the
|
||||
50,000-event or 10 MiB serialized trace cap, and is killed on timeout,
|
||||
cancellation or crash.
|
||||
- PCRE2 runs only in mandatory UTF/UCP mode. Exact UTF-8 byte boundaries are
|
||||
normalized to editor UTF-16 offsets; lone surrogates are rejected instead of
|
||||
being silently replaced by browser encoding.
|
||||
- Replacement templates are strings; executable callbacks are unavailable.
|
||||
- Replacement/list token decorations, rows, chips, diagnostics and list
|
||||
evaluation work have separate presentation/operation caps; incomplete list
|
||||
@@ -25,14 +35,51 @@ untrusted.
|
||||
- Unit-test assertion diagnostics never stringify complete large values and
|
||||
retain at most 2 KiB UTF-8 per case. Oversized replacement output is not
|
||||
copied into an exact current-result draft.
|
||||
- Corpus file input is local, recognized UTF-8 text only. It is bounded before
|
||||
decoding, rejects NUL/binary input, runs sequentially in a dedicated
|
||||
cancellable supervisor and never modifies source files.
|
||||
- Corpus JSON/CSV/NDJSON summaries never contain source, capture-sample or
|
||||
applied-output content. CSV cells are formula-injection protected. Local UI
|
||||
previews are explicitly bounded. Applied files require an explicit
|
||||
content-export acknowledgement; names are normalized against archive path
|
||||
traversal and collisions. Incomplete applied output cannot be downloaded or
|
||||
included in the all-document ZIP, whose bytes have a separate hard limit.
|
||||
- Corpus documents, contents, outputs and results are never placed in project
|
||||
JSON or IndexedDB. Schema-v1 imports that attempt to inject corpus payloads
|
||||
are rejected.
|
||||
- ECMAScript growth analysis preflights repetition count and UTF-8 bytes before
|
||||
allocating a generated string. The analysis worker is terminated on timeout,
|
||||
cancellation or crash; replacement timing also preflights its output bound.
|
||||
Analysis settings and results are neither persisted nor uploaded.
|
||||
- Generated-case synthesis runs in its own killable worker under AST, depth,
|
||||
attempt, repetition and UTF-8 byte caps. Labels come only from bounded
|
||||
requests to the selected real engine worker; a wrong outcome, timeout, crash,
|
||||
cancellation or compile rejection is never converted into a verified case.
|
||||
Settings, discarded candidates and unselected results are ephemeral and
|
||||
never uploaded.
|
||||
- Imported generated-test provenance is accepted only for the exact supported
|
||||
generator id/version and must agree with the test assertion outcome. Unknown
|
||||
provenance fields and future versions are rejected; editing a case removes
|
||||
its provenance.
|
||||
- ECMAScript formatting consumes only exact accepted regexpp literal-token
|
||||
ranges and never treats PCRE2 as ECMAScript. Source/candidate syntax and
|
||||
execution use separate killable workers; compile failure, truncation,
|
||||
timeout asymmetry, engine-identity drift, incomplete tests or any semantic
|
||||
difference fails closed. Preview/results are ephemeral and apply requires an
|
||||
explicit confirmation.
|
||||
- Subject minimization rejects lone surrogates, caps input at 1 MiB UTF-8,
|
||||
evaluation count at 2,000 and aggregate wall time at 60 seconds. Each
|
||||
predicate check runs in the selected killable engine worker. Timeout, crash,
|
||||
cancellation and worker error remain distinct; truncated results fail
|
||||
closed. Subjects and results are ephemeral and never uploaded or persisted.
|
||||
- No backend, uploads, telemetry, remote corpus URL, CDN, remote font, `eval`,
|
||||
`Function`, arbitrary command line or unsafe HTML rendering exists.
|
||||
|
||||
Version 0.1.0 needs a CSP equivalent to:
|
||||
The PCRE2-enabled build needs a CSP equivalent to:
|
||||
|
||||
```text
|
||||
default-src 'self';
|
||||
script-src 'self';
|
||||
script-src 'self' 'wasm-unsafe-eval';
|
||||
worker-src 'self' blob:;
|
||||
connect-src 'self';
|
||||
img-src 'self' data:;
|
||||
@@ -44,8 +91,9 @@ base-uri 'none';
|
||||
form-action 'none';
|
||||
```
|
||||
|
||||
The Toolbox shell currently requires inline styles. No `unsafe-eval` or
|
||||
`wasm-unsafe-eval` is required until an actual WebAssembly flavour ships.
|
||||
The Toolbox shell currently requires inline styles. Corpus ZIP generation and
|
||||
PCRE2 use self-hosted modules and local workers under the existing
|
||||
`worker-src` policy. Broad `unsafe-eval` is not required.
|
||||
|
||||
Report vulnerabilities privately to the repository owner before public issue
|
||||
details are posted.
|
||||
|
||||
71
engines/pcre2/CMakeLists.txt
Normal file
71
engines/pcre2/CMakeLists.txt
Normal file
@@ -0,0 +1,71 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
cmake_minimum_required(VERSION 3.25)
|
||||
project(regex_pcre2_bridge LANGUAGES C)
|
||||
|
||||
if(NOT EMSCRIPTEN)
|
||||
message(FATAL_ERROR "The staged PCRE2 pack must be built with Emscripten.")
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED PCRE2_SOURCE_DIR OR NOT IS_DIRECTORY "${PCRE2_SOURCE_DIR}")
|
||||
message(FATAL_ERROR "PCRE2_SOURCE_DIR must identify the verified PCRE2 source checkout.")
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED REGEX_PCRE2_OUTPUT_DIR)
|
||||
message(FATAL_ERROR "REGEX_PCRE2_OUTPUT_DIR must identify the private staging directory.")
|
||||
endif()
|
||||
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_STATIC_LIBS ON CACHE BOOL "" FORCE)
|
||||
set(PCRE2_BUILD_PCRE2_8 ON CACHE BOOL "" FORCE)
|
||||
set(PCRE2_BUILD_PCRE2_16 OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_BUILD_PCRE2_32 OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_BUILD_PCRE2GREP OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_DEBUG OFF CACHE STRING "" FORCE)
|
||||
set(PCRE2_REBUILD_CHARTABLES OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_SHOW_REPORT OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_STATIC_PIC OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_SUPPORT_JIT OFF CACHE BOOL "" FORCE)
|
||||
set(PCRE2_SUPPORT_UNICODE ON CACHE BOOL "" FORCE)
|
||||
|
||||
add_subdirectory("${PCRE2_SOURCE_DIR}" pcre2-source EXCLUDE_FROM_ALL)
|
||||
|
||||
add_executable(regex-pcre2-pack pcre2_bridge.c)
|
||||
target_compile_features(regex-pcre2-pack PRIVATE c_std_11)
|
||||
target_compile_definitions(regex-pcre2-pack PRIVATE PCRE2_CODE_UNIT_WIDTH=8)
|
||||
target_compile_options(
|
||||
regex-pcre2-pack
|
||||
PRIVATE
|
||||
-O3
|
||||
-fno-ident
|
||||
-fvisibility=hidden
|
||||
)
|
||||
target_link_libraries(regex-pcre2-pack PRIVATE pcre2-8-static)
|
||||
target_link_options(
|
||||
regex-pcre2-pack
|
||||
PRIVATE
|
||||
-O3
|
||||
--no-entry
|
||||
"SHELL:-s ALLOW_MEMORY_GROWTH=1"
|
||||
"SHELL:-s ASSERTIONS=0"
|
||||
"SHELL:-s ENVIRONMENT=web,worker,node"
|
||||
"SHELL:-s EXPORT_ES6=1"
|
||||
"SHELL:-s EXPORT_NAME=createRegexPcre2"
|
||||
"SHELL:-s EXPORTED_FUNCTIONS=['_regex_pcre2_bridge_abi_version','_regex_pcre2_compile_probe','_regex_pcre2_config_flags','_regex_pcre2_error_message','_regex_pcre2_execute','_regex_pcre2_self_test','_regex_pcre2_substitute','_regex_pcre2_trace','_regex_pcre2_version','_malloc','_free']"
|
||||
"SHELL:-s EXPORTED_RUNTIME_METHODS=['HEAPU8','UTF8ToString']"
|
||||
"SHELL:-s FILESYSTEM=0"
|
||||
"SHELL:-s INITIAL_MEMORY=16777216"
|
||||
"SHELL:-s MALLOC=emmalloc"
|
||||
"SHELL:-s MAXIMUM_MEMORY=268435456"
|
||||
"SHELL:-s MODULARIZE=1"
|
||||
"SHELL:-s NO_EXIT_RUNTIME=1"
|
||||
"SHELL:-s STRICT=1"
|
||||
)
|
||||
set_target_properties(
|
||||
regex-pcre2-pack
|
||||
PROPERTIES
|
||||
OUTPUT_NAME pcre2
|
||||
RUNTIME_OUTPUT_DIRECTORY "${REGEX_PCRE2_OUTPUT_DIR}"
|
||||
SUFFIX ".mjs"
|
||||
)
|
||||
27
engines/pcre2/README.md
Normal file
27
engines/pcre2/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# PCRE2 engine bridge
|
||||
|
||||
This directory contains the GPL-3.0-or-later application bridge and
|
||||
deterministic build description for the bundled PCRE2 flavour. It deliberately
|
||||
does not vendor the upstream PCRE2 source tree or generated binaries.
|
||||
|
||||
ABI version 3 exposes:
|
||||
|
||||
- exact engine identity and configuration;
|
||||
- compile diagnostics with UTF-8 byte offsets;
|
||||
- bounded non-overlapping matching with copied group-zero/capture records and
|
||||
copied capture-name metadata;
|
||||
- bounded native `pcre2_substitute()` semantics over the same retained match
|
||||
sequence;
|
||||
- bounded automatic-callout tracing from one native `pcre2_match()` invocation,
|
||||
with copied event and mark data and an immediate native stop at either trace
|
||||
cap;
|
||||
- match-step, depth, heap, match-count, capture-row and output limits;
|
||||
- no native pointer or compiled-code handle across a call boundary.
|
||||
|
||||
All PCRE2 allocations are released before an ABI call returns. JavaScript owns
|
||||
and frees every input/result buffer in `finally`, and the containing worker is
|
||||
terminated on timeout, crash, cancellation or supersession.
|
||||
|
||||
The explicit offline source/toolchain identity, build, verification and local
|
||||
asset-install workflow is documented in
|
||||
[`docs/ENGINE_BUILDS.md`](../../docs/ENGINE_BUILDS.md).
|
||||
910
engines/pcre2/pcre2_bridge.c
Normal file
910
engines/pcre2/pcre2_bridge.c
Normal file
@@ -0,0 +1,910 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
|
||||
#define PCRE2_CODE_UNIT_WIDTH 8
|
||||
|
||||
#include "pcre2_bridge.h"
|
||||
|
||||
#include <pcre2.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#define REGEX_PCRE2_COMPILE_FLAGS \
|
||||
(REGEX_PCRE2_CASELESS | REGEX_PCRE2_MULTILINE | REGEX_PCRE2_DOTALL | \
|
||||
REGEX_PCRE2_EXTENDED | REGEX_PCRE2_UNGREEDY | REGEX_PCRE2_UTF | \
|
||||
REGEX_PCRE2_UCP | REGEX_PCRE2_DUPNAMES)
|
||||
#define REGEX_PCRE2_ALL_FLAGS \
|
||||
(REGEX_PCRE2_COMPILE_FLAGS | REGEX_PCRE2_GLOBAL)
|
||||
|
||||
_Static_assert(sizeof(regex_pcre2_compile_result) == 20,
|
||||
"Compile result is part of ABI version 3.");
|
||||
_Static_assert(sizeof(regex_pcre2_limits) == 20,
|
||||
"Limits record is part of ABI version 3.");
|
||||
_Static_assert(sizeof(regex_pcre2_match_record) == 16,
|
||||
"Match record is part of ABI version 3.");
|
||||
_Static_assert(sizeof(regex_pcre2_name_record) == 12,
|
||||
"Name record is part of ABI version 3.");
|
||||
_Static_assert(sizeof(regex_pcre2_run_result) == 60,
|
||||
"Run result is part of ABI version 3.");
|
||||
_Static_assert(sizeof(regex_pcre2_trace_limits) == 20,
|
||||
"Trace limits are part of ABI version 3.");
|
||||
_Static_assert(sizeof(regex_pcre2_trace_event) == 32,
|
||||
"Trace event is part of ABI version 3.");
|
||||
_Static_assert(sizeof(regex_pcre2_trace_result) == 52,
|
||||
"Trace result is part of ABI version 3.");
|
||||
|
||||
typedef struct regex_pcre2_run_buffers {
|
||||
regex_pcre2_match_record *records;
|
||||
uint32_t record_capacity;
|
||||
regex_pcre2_name_record *name_records;
|
||||
uint32_t name_record_capacity;
|
||||
uint8_t *name_bytes;
|
||||
uint32_t name_bytes_capacity;
|
||||
uint8_t *output;
|
||||
uint32_t output_capacity;
|
||||
} regex_pcre2_run_buffers;
|
||||
|
||||
typedef struct regex_pcre2_trace_state {
|
||||
regex_pcre2_trace_event *events;
|
||||
uint32_t event_capacity;
|
||||
uint8_t *mark_bytes;
|
||||
uint32_t mark_bytes_capacity;
|
||||
uint32_t maximum_events;
|
||||
uint32_t maximum_trace_bytes;
|
||||
uint32_t event_count;
|
||||
uint32_t total_event_count;
|
||||
uint32_t mark_bytes_length;
|
||||
uint32_t events_truncated;
|
||||
uint32_t marks_truncated;
|
||||
} regex_pcre2_trace_state;
|
||||
|
||||
static const uint8_t empty_bytes[1] = {0};
|
||||
|
||||
static uint32_t to_pcre2_options(uint32_t application_flags) {
|
||||
uint32_t options = 0;
|
||||
if ((application_flags & REGEX_PCRE2_CASELESS) != 0) {
|
||||
options |= PCRE2_CASELESS;
|
||||
}
|
||||
if ((application_flags & REGEX_PCRE2_MULTILINE) != 0) {
|
||||
options |= PCRE2_MULTILINE;
|
||||
}
|
||||
if ((application_flags & REGEX_PCRE2_DOTALL) != 0) {
|
||||
options |= PCRE2_DOTALL;
|
||||
}
|
||||
if ((application_flags & REGEX_PCRE2_EXTENDED) != 0) {
|
||||
options |= PCRE2_EXTENDED;
|
||||
}
|
||||
if ((application_flags & REGEX_PCRE2_UNGREEDY) != 0) {
|
||||
options |= PCRE2_UNGREEDY;
|
||||
}
|
||||
if ((application_flags & REGEX_PCRE2_UTF) != 0) {
|
||||
options |= PCRE2_UTF;
|
||||
}
|
||||
if ((application_flags & REGEX_PCRE2_UCP) != 0) {
|
||||
options |= PCRE2_UCP;
|
||||
}
|
||||
if ((application_flags & REGEX_PCRE2_DUPNAMES) != 0) {
|
||||
options |= PCRE2_DUPNAMES;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
static int32_t fail_compile(regex_pcre2_compile_result *result,
|
||||
int32_t status) {
|
||||
if (result != NULL) {
|
||||
memset(result, 0, sizeof(*result));
|
||||
result->status = status;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
static int32_t fail_run(regex_pcre2_run_result *result,
|
||||
int32_t status,
|
||||
uint32_t phase,
|
||||
uint32_t offset) {
|
||||
if (result != NULL) {
|
||||
memset(result, 0, sizeof(*result));
|
||||
result->status = status;
|
||||
result->error_phase = phase;
|
||||
result->error_offset = offset;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
static int32_t fail_trace(regex_pcre2_trace_result *result,
|
||||
int32_t status,
|
||||
uint32_t phase,
|
||||
uint32_t offset) {
|
||||
if (result != NULL) {
|
||||
memset(result, 0, sizeof(*result));
|
||||
result->status = status;
|
||||
result->error_phase = phase;
|
||||
result->error_offset = offset;
|
||||
result->last_complete_event = UINT32_MAX;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
static int input_pointer_valid(const uint8_t *value, uint32_t length) {
|
||||
return value != NULL || length == 0;
|
||||
}
|
||||
|
||||
static const uint8_t *nonnull_input(const uint8_t *value) {
|
||||
return value == NULL ? empty_bytes : value;
|
||||
}
|
||||
|
||||
static int limits_valid(const regex_pcre2_limits *limits) {
|
||||
return limits != NULL && limits->maximum_matches > 0 &&
|
||||
limits->maximum_matches <= REGEX_PCRE2_MAX_MATCHES &&
|
||||
limits->maximum_capture_rows > 0 &&
|
||||
limits->maximum_capture_rows <= REGEX_PCRE2_MAX_CAPTURE_ROWS &&
|
||||
limits->match_limit > 0 &&
|
||||
limits->match_limit <= REGEX_PCRE2_MAX_MATCH_LIMIT &&
|
||||
limits->depth_limit > 0 &&
|
||||
limits->depth_limit <= REGEX_PCRE2_MAX_DEPTH_LIMIT &&
|
||||
limits->heap_limit_kib > 0 &&
|
||||
limits->heap_limit_kib <= REGEX_PCRE2_MAX_HEAP_LIMIT_KIB;
|
||||
}
|
||||
|
||||
static int buffers_valid(const regex_pcre2_run_buffers *buffers,
|
||||
int substitution) {
|
||||
if (buffers == NULL ||
|
||||
(buffers->records == NULL && buffers->record_capacity != 0) ||
|
||||
(buffers->name_records == NULL &&
|
||||
buffers->name_record_capacity != 0) ||
|
||||
(buffers->name_bytes == NULL && buffers->name_bytes_capacity != 0)) {
|
||||
return 0;
|
||||
}
|
||||
/*
|
||||
* PCRE2 writes a trailing NUL even when the semantic output capacity is
|
||||
* zero, so the bridge contract always requires the advertised +1 byte.
|
||||
*/
|
||||
if (substitution && buffers->output == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static uint32_t bounded_offset(PCRE2_SIZE value) {
|
||||
return value > UINT32_MAX ? UINT32_MAX : (uint32_t)value;
|
||||
}
|
||||
|
||||
static int32_t copy_names(const pcre2_code *code,
|
||||
regex_pcre2_run_buffers *buffers,
|
||||
regex_pcre2_run_result *result) {
|
||||
uint32_t entry_size = 0;
|
||||
PCRE2_SPTR table = NULL;
|
||||
uint32_t index = 0;
|
||||
uint32_t name_offset = 0;
|
||||
|
||||
if (result->name_count == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_NAMEENTRYSIZE, &entry_size) != 0 ||
|
||||
pcre2_pattern_info(code, PCRE2_INFO_NAMETABLE, &table) != 0 ||
|
||||
entry_size < 3 || table == NULL) {
|
||||
return REGEX_PCRE2_ERROR_CONFIG;
|
||||
}
|
||||
for (index = 0; index < result->name_count; index += 1) {
|
||||
const uint8_t *entry = table + (index * entry_size);
|
||||
uint32_t group_number =
|
||||
((uint32_t)entry[0] << 8) | (uint32_t)entry[1];
|
||||
uint32_t maximum_name = entry_size - 2;
|
||||
uint32_t name_length = 0;
|
||||
while (name_length < maximum_name && entry[2 + name_length] != 0) {
|
||||
name_length += 1;
|
||||
}
|
||||
if (result->name_record_count >= buffers->name_record_capacity ||
|
||||
name_length > buffers->name_bytes_capacity - name_offset) {
|
||||
result->names_truncated = 1;
|
||||
break;
|
||||
}
|
||||
buffers->name_records[result->name_record_count].group_number =
|
||||
group_number;
|
||||
buffers->name_records[result->name_record_count].name_offset =
|
||||
name_offset;
|
||||
buffers->name_records[result->name_record_count].name_length =
|
||||
name_length;
|
||||
if (name_length > 0) {
|
||||
memcpy(buffers->name_bytes + name_offset, entry + 2, name_length);
|
||||
}
|
||||
result->name_record_count += 1;
|
||||
name_offset += name_length;
|
||||
}
|
||||
result->name_bytes_length = name_offset;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_utf8_prefix(regex_pcre2_run_buffers *buffers,
|
||||
regex_pcre2_run_result *result,
|
||||
const uint8_t *source,
|
||||
uint32_t length) {
|
||||
uint32_t remaining = buffers->output_capacity - result->output_length;
|
||||
uint32_t copied = length < remaining ? length : remaining;
|
||||
|
||||
if (copied < length) {
|
||||
while (copied > 0 && (source[copied] & 0xc0u) == 0x80u) {
|
||||
copied -= 1;
|
||||
}
|
||||
result->output_truncated = 1;
|
||||
}
|
||||
if (copied > 0) {
|
||||
memcpy(buffers->output + result->output_length, source, copied);
|
||||
result->output_length += copied;
|
||||
}
|
||||
return copied == length;
|
||||
}
|
||||
|
||||
static int32_t configure_match_context_values(pcre2_match_context *context,
|
||||
uint32_t match_limit,
|
||||
uint32_t depth_limit,
|
||||
uint32_t heap_limit_kib) {
|
||||
if (pcre2_set_match_limit(context, match_limit) != 0 ||
|
||||
pcre2_set_depth_limit(context, depth_limit) != 0 ||
|
||||
pcre2_set_heap_limit(context, heap_limit_kib) != 0) {
|
||||
return REGEX_PCRE2_ERROR_CONFIG;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int32_t configure_match_context(pcre2_match_context *context,
|
||||
const regex_pcre2_limits *limits) {
|
||||
return configure_match_context_values(
|
||||
context, limits->match_limit, limits->depth_limit,
|
||||
limits->heap_limit_kib);
|
||||
}
|
||||
|
||||
static int trace_limits_valid(const regex_pcre2_trace_limits *limits) {
|
||||
return limits != NULL && limits->maximum_events > 0 &&
|
||||
limits->maximum_events <= REGEX_PCRE2_MAX_TRACE_EVENTS &&
|
||||
limits->maximum_trace_bytes >= sizeof(regex_pcre2_trace_event) &&
|
||||
limits->maximum_trace_bytes <= REGEX_PCRE2_MAX_TRACE_BYTES &&
|
||||
limits->match_limit > 0 &&
|
||||
limits->match_limit <= REGEX_PCRE2_MAX_MATCH_LIMIT &&
|
||||
limits->depth_limit > 0 &&
|
||||
limits->depth_limit <= REGEX_PCRE2_MAX_DEPTH_LIMIT &&
|
||||
limits->heap_limit_kib > 0 &&
|
||||
limits->heap_limit_kib <= REGEX_PCRE2_MAX_HEAP_LIMIT_KIB;
|
||||
}
|
||||
|
||||
static uint32_t bounded_mark_length(PCRE2_SPTR mark, uint32_t *truncated) {
|
||||
uint32_t length = 0;
|
||||
if (mark == NULL) {
|
||||
return 0;
|
||||
}
|
||||
while (length < REGEX_PCRE2_MAX_TRACE_MARK_BYTES && mark[length] != 0) {
|
||||
length += 1;
|
||||
}
|
||||
if (length == REGEX_PCRE2_MAX_TRACE_MARK_BYTES && mark[length] != 0) {
|
||||
*truncated = 1;
|
||||
while (length > 0 && (mark[length] & 0xc0u) == 0x80u) {
|
||||
length -= 1;
|
||||
}
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
static int trace_callout(pcre2_callout_block *block, void *data) {
|
||||
regex_pcre2_trace_state *state = (regex_pcre2_trace_state *)data;
|
||||
regex_pcre2_trace_event *event = NULL;
|
||||
uint32_t mark_length = 0;
|
||||
uint32_t mark_was_truncated = 0;
|
||||
uint64_t required_trace_bytes = 0;
|
||||
|
||||
if (block == NULL || state == NULL) {
|
||||
return PCRE2_ERROR_CALLOUT;
|
||||
}
|
||||
if (state->total_event_count < UINT32_MAX) {
|
||||
state->total_event_count += 1;
|
||||
}
|
||||
mark_length = bounded_mark_length(block->mark, &mark_was_truncated);
|
||||
required_trace_bytes =
|
||||
((uint64_t)state->event_count + 1u) *
|
||||
sizeof(regex_pcre2_trace_event) +
|
||||
state->mark_bytes_length + mark_length;
|
||||
if (state->event_count >= state->maximum_events ||
|
||||
state->event_count >= state->event_capacity ||
|
||||
required_trace_bytes > state->maximum_trace_bytes ||
|
||||
mark_length > state->mark_bytes_capacity - state->mark_bytes_length) {
|
||||
state->events_truncated = 1;
|
||||
return PCRE2_ERROR_CALLOUT;
|
||||
}
|
||||
|
||||
event = &state->events[state->event_count];
|
||||
event->callout_number = block->callout_number;
|
||||
event->pattern_position_byte = bounded_offset(block->pattern_position);
|
||||
event->next_item_length_byte = bounded_offset(block->next_item_length);
|
||||
event->subject_position_byte = bounded_offset(block->current_position);
|
||||
event->capture_top = block->capture_top;
|
||||
event->capture_last = block->capture_last;
|
||||
event->mark_offset = state->mark_bytes_length;
|
||||
event->mark_length = mark_length;
|
||||
if (mark_length > 0) {
|
||||
memcpy(state->mark_bytes + state->mark_bytes_length, block->mark,
|
||||
mark_length);
|
||||
state->mark_bytes_length += mark_length;
|
||||
}
|
||||
state->marks_truncated |= mark_was_truncated;
|
||||
state->event_count += 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int32_t run_bounded(const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
const uint8_t *subject,
|
||||
uint32_t subject_length,
|
||||
const uint8_t *replacement,
|
||||
uint32_t replacement_length,
|
||||
uint32_t application_flags,
|
||||
const regex_pcre2_limits *limits,
|
||||
regex_pcre2_run_buffers *buffers,
|
||||
int substitution,
|
||||
regex_pcre2_run_result *result) {
|
||||
pcre2_code *code = NULL;
|
||||
pcre2_match_data *match_data = NULL;
|
||||
pcre2_match_context *match_context = NULL;
|
||||
int compile_error = 0;
|
||||
PCRE2_SIZE compile_offset = 0;
|
||||
uint32_t capture_count = 0;
|
||||
uint32_t name_count = 0;
|
||||
uint32_t options = 0;
|
||||
uint32_t search_offset = 0;
|
||||
uint32_t match_options = 0;
|
||||
uint32_t copied_until = 0;
|
||||
uint32_t capture_rows = 0;
|
||||
int32_t status = 0;
|
||||
const uint8_t *safe_pattern = nonnull_input(pattern);
|
||||
const uint8_t *safe_subject = nonnull_input(subject);
|
||||
const uint8_t *safe_replacement = nonnull_input(replacement);
|
||||
|
||||
if (result == NULL || !input_pointer_valid(pattern, pattern_length) ||
|
||||
!input_pointer_valid(subject, subject_length) ||
|
||||
(substitution &&
|
||||
!input_pointer_valid(replacement, replacement_length)) ||
|
||||
!buffers_valid(buffers, substitution)) {
|
||||
return fail_run(result, REGEX_PCRE2_ERROR_INVALID_ARGUMENT,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
memset(result, 0, sizeof(*result));
|
||||
|
||||
if ((application_flags & ~REGEX_PCRE2_ALL_FLAGS) != 0) {
|
||||
return fail_run(result, REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
if (pattern_length > REGEX_PCRE2_MAX_PATTERN_BYTES) {
|
||||
return fail_run(result, REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
if (subject_length > REGEX_PCRE2_MAX_SUBJECT_BYTES) {
|
||||
return fail_run(result, REGEX_PCRE2_ERROR_SUBJECT_TOO_LARGE,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
if (substitution &&
|
||||
replacement_length > REGEX_PCRE2_MAX_REPLACEMENT_BYTES) {
|
||||
return fail_run(result, REGEX_PCRE2_ERROR_REPLACEMENT_TOO_LARGE,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
if (!limits_valid(limits)) {
|
||||
return fail_run(result, REGEX_PCRE2_ERROR_LIMIT_OUT_OF_RANGE,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
|
||||
options = to_pcre2_options(application_flags);
|
||||
result->effective_options = options;
|
||||
code = pcre2_compile(safe_pattern, pattern_length, options, &compile_error,
|
||||
&compile_offset, NULL);
|
||||
if (code == NULL) {
|
||||
status = compile_error;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_COMPILE;
|
||||
result->error_offset = bounded_offset(compile_offset);
|
||||
goto cleanup;
|
||||
}
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0 ||
|
||||
pcre2_pattern_info(code, PCRE2_INFO_NAMECOUNT, &name_count) != 0) {
|
||||
status = REGEX_PCRE2_ERROR_CONFIG;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
result->capture_count = capture_count;
|
||||
result->name_count = name_count;
|
||||
if (capture_count > REGEX_PCRE2_MAX_CAPTURE_GROUPS) {
|
||||
status = REGEX_PCRE2_ERROR_CAPTURE_LIMIT;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
status = copy_names(code, buffers, result);
|
||||
if (status != 0) {
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
match_data = pcre2_match_data_create_from_pattern(code, NULL);
|
||||
match_context = pcre2_match_context_create(NULL);
|
||||
if (match_data == NULL || match_context == NULL) {
|
||||
status = PCRE2_ERROR_NOMEMORY;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
status = configure_match_context(match_context, limits);
|
||||
if (status != 0) {
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
while (search_offset <= subject_length) {
|
||||
int match_status =
|
||||
pcre2_match(code, safe_subject, subject_length, search_offset,
|
||||
match_options, match_data, match_context);
|
||||
PCRE2_SIZE *ovector = NULL;
|
||||
uint32_t required_records = capture_count + 1;
|
||||
uint32_t required_capture_rows = capture_count == 0 ? 1 : capture_count;
|
||||
uint32_t group = 0;
|
||||
uint32_t start = 0;
|
||||
uint32_t end = 0;
|
||||
|
||||
if (match_status == PCRE2_ERROR_NOMATCH) {
|
||||
break;
|
||||
}
|
||||
if (match_status < 0) {
|
||||
status = match_status;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_MATCH;
|
||||
result->error_offset = search_offset;
|
||||
goto cleanup;
|
||||
}
|
||||
if (match_status == 0) {
|
||||
status = REGEX_PCRE2_ERROR_CONFIG;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
ovector = pcre2_get_ovector_pointer(match_data);
|
||||
start = bounded_offset(ovector[0]);
|
||||
end = bounded_offset(ovector[1]);
|
||||
if (start > end || end > subject_length) {
|
||||
status = REGEX_PCRE2_ERROR_CONFIG;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
if (result->match_count >= limits->maximum_matches ||
|
||||
capture_rows + required_capture_rows >
|
||||
limits->maximum_capture_rows ||
|
||||
required_records > buffers->record_capacity - result->record_count) {
|
||||
result->results_truncated = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
for (group = 0; group <= capture_count; group += 1) {
|
||||
regex_pcre2_match_record *record =
|
||||
&buffers->records[result->record_count];
|
||||
PCRE2_SIZE native_start = ovector[group * 2];
|
||||
PCRE2_SIZE native_end = ovector[group * 2 + 1];
|
||||
record->match_number = result->match_count + 1;
|
||||
record->group_number = group;
|
||||
if (native_start == PCRE2_UNSET || native_end == PCRE2_UNSET) {
|
||||
record->start_byte = REGEX_PCRE2_UNSET_OFFSET;
|
||||
record->end_byte = REGEX_PCRE2_UNSET_OFFSET;
|
||||
} else {
|
||||
record->start_byte = bounded_offset(native_start);
|
||||
record->end_byte = bounded_offset(native_end);
|
||||
}
|
||||
result->record_count += 1;
|
||||
}
|
||||
result->match_count += 1;
|
||||
capture_rows += required_capture_rows;
|
||||
|
||||
if (substitution && result->output_truncated == 0) {
|
||||
PCRE2_SIZE replacement_output_length =
|
||||
buffers->output_capacity - result->output_length + 1;
|
||||
int substitute_status = 0;
|
||||
if (!append_utf8_prefix(buffers, result, safe_subject + copied_until,
|
||||
start - copied_until)) {
|
||||
break;
|
||||
}
|
||||
replacement_output_length =
|
||||
buffers->output_capacity - result->output_length + 1;
|
||||
substitute_status = pcre2_substitute(
|
||||
code, safe_subject, subject_length, search_offset,
|
||||
match_options | PCRE2_SUBSTITUTE_MATCHED |
|
||||
PCRE2_SUBSTITUTE_REPLACEMENT_ONLY |
|
||||
PCRE2_SUBSTITUTE_UNSET_EMPTY,
|
||||
match_data, match_context, safe_replacement, replacement_length,
|
||||
buffers->output + result->output_length, &replacement_output_length);
|
||||
if (substitute_status == PCRE2_ERROR_NOMEMORY) {
|
||||
result->output_truncated = 1;
|
||||
break;
|
||||
}
|
||||
if (substitute_status < 0) {
|
||||
status = substitute_status;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_SUBSTITUTE;
|
||||
result->error_offset = bounded_offset(replacement_output_length);
|
||||
goto cleanup;
|
||||
}
|
||||
result->output_length += bounded_offset(replacement_output_length);
|
||||
result->substitution_count += (uint32_t)substitute_status;
|
||||
copied_until = end;
|
||||
}
|
||||
|
||||
if ((application_flags & REGEX_PCRE2_GLOBAL) == 0) {
|
||||
break;
|
||||
}
|
||||
{
|
||||
PCRE2_SIZE next_offset = search_offset;
|
||||
uint32_t next_options = 0;
|
||||
if (!pcre2_next_match(match_data, &next_offset, &next_options)) {
|
||||
break;
|
||||
}
|
||||
search_offset = bounded_offset(next_offset);
|
||||
match_options = next_options;
|
||||
}
|
||||
}
|
||||
|
||||
if (substitution && result->output_truncated == 0) {
|
||||
(void)append_utf8_prefix(buffers, result, safe_subject + copied_until,
|
||||
subject_length - copied_until);
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (match_context != NULL) {
|
||||
pcre2_match_context_free(match_context);
|
||||
}
|
||||
if (match_data != NULL) {
|
||||
pcre2_match_data_free(match_data);
|
||||
}
|
||||
if (code != NULL) {
|
||||
pcre2_code_free(code);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
uint32_t regex_pcre2_bridge_abi_version(void) {
|
||||
return REGEX_PCRE2_BRIDGE_ABI_VERSION;
|
||||
}
|
||||
|
||||
uint32_t regex_pcre2_config_flags(void) {
|
||||
uint32_t unicode = 0;
|
||||
uint32_t jit = 0;
|
||||
uint32_t flags = 0;
|
||||
|
||||
if (pcre2_config(PCRE2_CONFIG_UNICODE, &unicode) != 0 ||
|
||||
pcre2_config(PCRE2_CONFIG_JIT, &jit) != 0) {
|
||||
return 0;
|
||||
}
|
||||
if (unicode != 0) {
|
||||
flags |= REGEX_PCRE2_CONFIG_UNICODE;
|
||||
}
|
||||
if (jit != 0) {
|
||||
flags |= REGEX_PCRE2_CONFIG_JIT;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const uint8_t *regex_pcre2_version(void) {
|
||||
static uint8_t version[64];
|
||||
if (pcre2_config(PCRE2_CONFIG_VERSION, version) <= 0) {
|
||||
version[0] = '\0';
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
int32_t regex_pcre2_error_message(int32_t error_code,
|
||||
uint8_t *output,
|
||||
uint32_t output_capacity) {
|
||||
int status = 0;
|
||||
if (output == NULL || output_capacity == 0) {
|
||||
return REGEX_PCRE2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
status = pcre2_get_error_message(error_code, output, output_capacity);
|
||||
if (status < 0) {
|
||||
output[0] = '\0';
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
int32_t regex_pcre2_compile_probe(const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
uint32_t application_flags,
|
||||
regex_pcre2_compile_result *result) {
|
||||
pcre2_code *code = NULL;
|
||||
int error_code = 0;
|
||||
PCRE2_SIZE error_offset = 0;
|
||||
uint32_t capture_count = 0;
|
||||
uint32_t name_count = 0;
|
||||
uint32_t options = 0;
|
||||
|
||||
if (result == NULL || !input_pointer_valid(pattern, pattern_length)) {
|
||||
return fail_compile(result, REGEX_PCRE2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
memset(result, 0, sizeof(*result));
|
||||
|
||||
if ((application_flags & ~REGEX_PCRE2_COMPILE_FLAGS) != 0) {
|
||||
return fail_compile(result, REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS);
|
||||
}
|
||||
if (pattern_length > REGEX_PCRE2_MAX_PATTERN_BYTES) {
|
||||
return fail_compile(result, REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE);
|
||||
}
|
||||
|
||||
options = to_pcre2_options(application_flags);
|
||||
result->effective_options = options;
|
||||
code = pcre2_compile(nonnull_input(pattern), pattern_length, options,
|
||||
&error_code, &error_offset, NULL);
|
||||
if (code == NULL) {
|
||||
result->status = error_code;
|
||||
result->error_offset = bounded_offset(error_offset);
|
||||
return error_code;
|
||||
}
|
||||
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0 ||
|
||||
pcre2_pattern_info(code, PCRE2_INFO_NAMECOUNT, &name_count) != 0) {
|
||||
pcre2_code_free(code);
|
||||
return fail_compile(result, REGEX_PCRE2_ERROR_CONFIG);
|
||||
}
|
||||
|
||||
result->capture_count = capture_count;
|
||||
result->name_count = name_count;
|
||||
pcre2_code_free(code);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int32_t regex_pcre2_execute(
|
||||
const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
const uint8_t *subject,
|
||||
uint32_t subject_length,
|
||||
uint32_t application_flags,
|
||||
const regex_pcre2_limits *limits,
|
||||
regex_pcre2_match_record *records,
|
||||
uint32_t record_capacity,
|
||||
regex_pcre2_name_record *name_records,
|
||||
uint32_t name_record_capacity,
|
||||
uint8_t *name_bytes,
|
||||
uint32_t name_bytes_capacity,
|
||||
regex_pcre2_run_result *result) {
|
||||
regex_pcre2_run_buffers buffers = {
|
||||
records, record_capacity, name_records, name_record_capacity,
|
||||
name_bytes, name_bytes_capacity, NULL, 0};
|
||||
return run_bounded(pattern, pattern_length, subject, subject_length, NULL, 0,
|
||||
application_flags, limits, &buffers, 0, result);
|
||||
}
|
||||
|
||||
int32_t regex_pcre2_substitute(
|
||||
const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
const uint8_t *subject,
|
||||
uint32_t subject_length,
|
||||
const uint8_t *replacement,
|
||||
uint32_t replacement_length,
|
||||
uint32_t application_flags,
|
||||
const regex_pcre2_limits *limits,
|
||||
regex_pcre2_match_record *records,
|
||||
uint32_t record_capacity,
|
||||
regex_pcre2_name_record *name_records,
|
||||
uint32_t name_record_capacity,
|
||||
uint8_t *name_bytes,
|
||||
uint32_t name_bytes_capacity,
|
||||
uint8_t *output,
|
||||
uint32_t output_capacity,
|
||||
regex_pcre2_run_result *result) {
|
||||
regex_pcre2_run_buffers buffers = {
|
||||
records, record_capacity, name_records, name_record_capacity,
|
||||
name_bytes, name_bytes_capacity, output, output_capacity};
|
||||
return run_bounded(pattern, pattern_length, subject, subject_length,
|
||||
replacement, replacement_length, application_flags,
|
||||
limits, &buffers, 1, result);
|
||||
}
|
||||
|
||||
int32_t regex_pcre2_trace(
|
||||
const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
const uint8_t *subject,
|
||||
uint32_t subject_length,
|
||||
uint32_t application_flags,
|
||||
const regex_pcre2_trace_limits *limits,
|
||||
regex_pcre2_trace_event *events,
|
||||
uint32_t event_capacity,
|
||||
uint8_t *mark_bytes,
|
||||
uint32_t mark_bytes_capacity,
|
||||
regex_pcre2_trace_result *result) {
|
||||
pcre2_code *code = NULL;
|
||||
pcre2_match_data *match_data = NULL;
|
||||
pcre2_match_context *match_context = NULL;
|
||||
regex_pcre2_trace_state state;
|
||||
int compile_error = 0;
|
||||
PCRE2_SIZE compile_offset = 0;
|
||||
uint32_t capture_count = 0;
|
||||
uint32_t options = 0;
|
||||
int32_t match_status = 0;
|
||||
int32_t status = 0;
|
||||
|
||||
if (result == NULL || !input_pointer_valid(pattern, pattern_length) ||
|
||||
!input_pointer_valid(subject, subject_length) || events == NULL ||
|
||||
event_capacity == 0 ||
|
||||
(mark_bytes == NULL && mark_bytes_capacity != 0)) {
|
||||
return fail_trace(result, REGEX_PCRE2_ERROR_INVALID_ARGUMENT,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
memset(result, 0, sizeof(*result));
|
||||
result->last_complete_event = UINT32_MAX;
|
||||
if ((application_flags & ~REGEX_PCRE2_ALL_FLAGS) != 0) {
|
||||
return fail_trace(result, REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
if (pattern_length > REGEX_PCRE2_MAX_PATTERN_BYTES) {
|
||||
return fail_trace(result, REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
if (subject_length > REGEX_PCRE2_MAX_SUBJECT_BYTES) {
|
||||
return fail_trace(result, REGEX_PCRE2_ERROR_SUBJECT_TOO_LARGE,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
if (!trace_limits_valid(limits) ||
|
||||
event_capacity < limits->maximum_events ||
|
||||
event_capacity > REGEX_PCRE2_MAX_TRACE_EVENTS ||
|
||||
mark_bytes_capacity > REGEX_PCRE2_MAX_TRACE_BYTES) {
|
||||
return fail_trace(result, REGEX_PCRE2_ERROR_LIMIT_OUT_OF_RANGE,
|
||||
REGEX_PCRE2_PHASE_BRIDGE, 0);
|
||||
}
|
||||
|
||||
options = to_pcre2_options(application_flags) | PCRE2_AUTO_CALLOUT;
|
||||
result->effective_options = options;
|
||||
code = pcre2_compile(nonnull_input(pattern), pattern_length, options,
|
||||
&compile_error, &compile_offset, NULL);
|
||||
if (code == NULL) {
|
||||
status = compile_error;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_COMPILE;
|
||||
result->error_offset = bounded_offset(compile_offset);
|
||||
goto cleanup;
|
||||
}
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0) {
|
||||
status = REGEX_PCRE2_ERROR_CONFIG;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
if (capture_count > REGEX_PCRE2_MAX_CAPTURE_GROUPS) {
|
||||
status = REGEX_PCRE2_ERROR_CAPTURE_LIMIT;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
match_data = pcre2_match_data_create_from_pattern(code, NULL);
|
||||
match_context = pcre2_match_context_create(NULL);
|
||||
if (match_data == NULL || match_context == NULL) {
|
||||
status = PCRE2_ERROR_NOMEMORY;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
memset(&state, 0, sizeof(state));
|
||||
state.events = events;
|
||||
state.event_capacity = event_capacity;
|
||||
state.mark_bytes = mark_bytes;
|
||||
state.mark_bytes_capacity = mark_bytes_capacity;
|
||||
state.maximum_events = limits->maximum_events;
|
||||
state.maximum_trace_bytes = limits->maximum_trace_bytes;
|
||||
status = configure_match_context_values(
|
||||
match_context, limits->match_limit, limits->depth_limit,
|
||||
limits->heap_limit_kib);
|
||||
if (status != 0 ||
|
||||
pcre2_set_callout(match_context, trace_callout, &state) != 0) {
|
||||
status = REGEX_PCRE2_ERROR_CONFIG;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_BRIDGE;
|
||||
goto cleanup;
|
||||
}
|
||||
/*
|
||||
* The global flag is intentionally application-level for normal execution.
|
||||
* A trace represents one exact pcre2_match() invocation.
|
||||
*/
|
||||
match_status = pcre2_match(code, nonnull_input(subject), subject_length, 0, 0,
|
||||
match_data, match_context);
|
||||
result->native_match_status = match_status;
|
||||
result->event_count = state.event_count;
|
||||
result->total_event_count = state.total_event_count;
|
||||
result->mark_bytes_length = state.mark_bytes_length;
|
||||
result->trace_bytes_length =
|
||||
state.event_count * (uint32_t)sizeof(regex_pcre2_trace_event) +
|
||||
state.mark_bytes_length;
|
||||
result->events_truncated = state.events_truncated;
|
||||
result->marks_truncated = state.marks_truncated;
|
||||
result->last_complete_event =
|
||||
state.event_count == 0 ? UINT32_MAX : state.event_count - 1;
|
||||
if (match_status >= 0) {
|
||||
result->matched = 1;
|
||||
} else if (match_status == PCRE2_ERROR_NOMATCH ||
|
||||
(match_status == PCRE2_ERROR_CALLOUT &&
|
||||
state.events_truncated != 0)) {
|
||||
status = 0;
|
||||
} else {
|
||||
status = match_status;
|
||||
result->status = status;
|
||||
result->error_phase = REGEX_PCRE2_PHASE_MATCH;
|
||||
if (state.event_count > 0) {
|
||||
result->error_offset =
|
||||
events[state.event_count - 1].subject_position_byte;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (match_context != NULL) {
|
||||
pcre2_match_context_free(match_context);
|
||||
}
|
||||
if (match_data != NULL) {
|
||||
pcre2_match_data_free(match_data);
|
||||
}
|
||||
if (code != NULL) {
|
||||
pcre2_code_free(code);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
int32_t regex_pcre2_self_test(void) {
|
||||
static const uint8_t pattern[] = "(?<word>\\p{L}+)-(?<number>\\d+)";
|
||||
static const uint8_t subject[] = "x Grüße-42 y";
|
||||
static const uint8_t replacement[] = "$<number>:$<word>";
|
||||
static const uint8_t trace_pattern[] = "a+b";
|
||||
static const uint8_t trace_subject[] = "aaab";
|
||||
regex_pcre2_compile_result compile_result;
|
||||
regex_pcre2_run_result run_result;
|
||||
regex_pcre2_trace_result trace_result;
|
||||
regex_pcre2_limits limits = {10, 100, 1000000, 1000, 32768};
|
||||
regex_pcre2_trace_limits trace_limits = {100, 4096, 1000000, 1000,
|
||||
32768};
|
||||
regex_pcre2_match_record records[3];
|
||||
regex_pcre2_name_record names[2];
|
||||
regex_pcre2_trace_event trace_events[100];
|
||||
uint8_t name_bytes[64];
|
||||
uint8_t trace_marks[64];
|
||||
uint8_t output[65];
|
||||
int32_t status = regex_pcre2_compile_probe(
|
||||
pattern, (uint32_t)(sizeof(pattern) - 1),
|
||||
REGEX_PCRE2_UTF | REGEX_PCRE2_UCP, &compile_result);
|
||||
|
||||
if (status != 0 || compile_result.capture_count != 2 ||
|
||||
compile_result.name_count != 2) {
|
||||
return 1;
|
||||
}
|
||||
status = regex_pcre2_substitute(
|
||||
pattern, (uint32_t)(sizeof(pattern) - 1), subject,
|
||||
(uint32_t)(sizeof(subject) - 1), replacement,
|
||||
(uint32_t)(sizeof(replacement) - 1),
|
||||
REGEX_PCRE2_UTF | REGEX_PCRE2_UCP, &limits, records, 3, names, 2,
|
||||
name_bytes, sizeof(name_bytes), output, sizeof(output) - 1, &run_result);
|
||||
if (status != 0 || run_result.match_count != 1 ||
|
||||
run_result.record_count != 3 || run_result.substitution_count != 1 ||
|
||||
run_result.output_truncated != 0 ||
|
||||
run_result.output_length != sizeof("x 42:Grüße y") - 1 ||
|
||||
memcmp(output, "x 42:Grüße y", sizeof("x 42:Grüße y") - 1) != 0) {
|
||||
return 2;
|
||||
}
|
||||
status = regex_pcre2_trace(
|
||||
trace_pattern, (uint32_t)(sizeof(trace_pattern) - 1), trace_subject,
|
||||
(uint32_t)(sizeof(trace_subject) - 1),
|
||||
REGEX_PCRE2_UTF | REGEX_PCRE2_UCP, &trace_limits, trace_events, 100,
|
||||
trace_marks, sizeof(trace_marks), &trace_result);
|
||||
if (status != 0 || trace_result.status != 0 ||
|
||||
trace_result.native_match_status <= 0 || trace_result.matched != 1 ||
|
||||
trace_result.event_count == 0 || trace_result.events_truncated != 0 ||
|
||||
trace_result.last_complete_event != trace_result.event_count - 1) {
|
||||
return 3;
|
||||
}
|
||||
if (regex_pcre2_bridge_abi_version() != REGEX_PCRE2_BRIDGE_ABI_VERSION ||
|
||||
regex_pcre2_config_flags() != REGEX_PCRE2_CONFIG_UNICODE) {
|
||||
return 4;
|
||||
}
|
||||
if (strcmp((const char *)regex_pcre2_version(), "10.47 2025-10-21") != 0) {
|
||||
return 5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
236
engines/pcre2/pcre2_bridge.h
Normal file
236
engines/pcre2/pcre2_bridge.h
Normal file
@@ -0,0 +1,236 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
|
||||
#ifndef REGEX_TOOLS_PCRE2_BRIDGE_H
|
||||
#define REGEX_TOOLS_PCRE2_BRIDGE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#define REGEX_PCRE2_BRIDGE_ABI_VERSION 3u
|
||||
#define REGEX_PCRE2_MAX_PATTERN_BYTES 1048576u
|
||||
#define REGEX_PCRE2_MAX_SUBJECT_BYTES 16777216u
|
||||
#define REGEX_PCRE2_MAX_REPLACEMENT_BYTES 262144u
|
||||
#define REGEX_PCRE2_MAX_MATCHES 10000u
|
||||
#define REGEX_PCRE2_MAX_CAPTURE_ROWS 100000u
|
||||
#define REGEX_PCRE2_MAX_CAPTURE_GROUPS 1000u
|
||||
#define REGEX_PCRE2_MAX_MATCH_LIMIT 100000000u
|
||||
#define REGEX_PCRE2_MAX_DEPTH_LIMIT 100000u
|
||||
#define REGEX_PCRE2_MAX_HEAP_LIMIT_KIB 131072u
|
||||
#define REGEX_PCRE2_MAX_TRACE_EVENTS 50000u
|
||||
#define REGEX_PCRE2_MAX_TRACE_BYTES 10485760u
|
||||
#define REGEX_PCRE2_MAX_TRACE_MARK_BYTES 1024u
|
||||
#define REGEX_PCRE2_UNSET_OFFSET UINT32_MAX
|
||||
|
||||
enum regex_pcre2_flag {
|
||||
REGEX_PCRE2_CASELESS = 1u << 0,
|
||||
REGEX_PCRE2_MULTILINE = 1u << 1,
|
||||
REGEX_PCRE2_DOTALL = 1u << 2,
|
||||
REGEX_PCRE2_EXTENDED = 1u << 3,
|
||||
REGEX_PCRE2_UNGREEDY = 1u << 4,
|
||||
REGEX_PCRE2_UTF = 1u << 5,
|
||||
REGEX_PCRE2_UCP = 1u << 6,
|
||||
REGEX_PCRE2_DUPNAMES = 1u << 7,
|
||||
REGEX_PCRE2_GLOBAL = 1u << 8
|
||||
};
|
||||
|
||||
enum regex_pcre2_config_flag {
|
||||
REGEX_PCRE2_CONFIG_UNICODE = 1u << 0,
|
||||
REGEX_PCRE2_CONFIG_JIT = 1u << 1
|
||||
};
|
||||
|
||||
enum regex_pcre2_bridge_error {
|
||||
REGEX_PCRE2_ERROR_INVALID_ARGUMENT = -10001,
|
||||
REGEX_PCRE2_ERROR_UNSUPPORTED_FLAGS = -10002,
|
||||
REGEX_PCRE2_ERROR_PATTERN_TOO_LARGE = -10003,
|
||||
REGEX_PCRE2_ERROR_CONFIG = -10004,
|
||||
REGEX_PCRE2_ERROR_SUBJECT_TOO_LARGE = -10005,
|
||||
REGEX_PCRE2_ERROR_REPLACEMENT_TOO_LARGE = -10006,
|
||||
REGEX_PCRE2_ERROR_LIMIT_OUT_OF_RANGE = -10007,
|
||||
REGEX_PCRE2_ERROR_CAPTURE_LIMIT = -10008,
|
||||
REGEX_PCRE2_ERROR_OUTPUT_BUFFER = -10009
|
||||
};
|
||||
|
||||
enum regex_pcre2_error_phase {
|
||||
REGEX_PCRE2_PHASE_NONE = 0,
|
||||
REGEX_PCRE2_PHASE_BRIDGE = 1,
|
||||
REGEX_PCRE2_PHASE_COMPILE = 2,
|
||||
REGEX_PCRE2_PHASE_MATCH = 3,
|
||||
REGEX_PCRE2_PHASE_SUBSTITUTE = 4
|
||||
};
|
||||
|
||||
/*
|
||||
* This 20-byte, five-u32 record is retained for deterministic compile-only
|
||||
* build verification. Runtime callers should use the bounded APIs below.
|
||||
*/
|
||||
typedef struct regex_pcre2_compile_result {
|
||||
int32_t status;
|
||||
uint32_t error_offset;
|
||||
uint32_t capture_count;
|
||||
uint32_t name_count;
|
||||
uint32_t effective_options;
|
||||
} regex_pcre2_compile_result;
|
||||
|
||||
/* All values are required and validated against the bridge hard maxima. */
|
||||
typedef struct regex_pcre2_limits {
|
||||
uint32_t maximum_matches;
|
||||
uint32_t maximum_capture_rows;
|
||||
uint32_t match_limit;
|
||||
uint32_t depth_limit;
|
||||
uint32_t heap_limit_kib;
|
||||
} regex_pcre2_limits;
|
||||
|
||||
/*
|
||||
* One record is emitted for group zero and every capture group of each
|
||||
* retained match. Unset captures use REGEX_PCRE2_UNSET_OFFSET for both bounds.
|
||||
*/
|
||||
typedef struct regex_pcre2_match_record {
|
||||
uint32_t match_number;
|
||||
uint32_t group_number;
|
||||
uint32_t start_byte;
|
||||
uint32_t end_byte;
|
||||
} regex_pcre2_match_record;
|
||||
|
||||
/* Names are UTF-8 slices into the caller-owned name byte buffer. */
|
||||
typedef struct regex_pcre2_name_record {
|
||||
uint32_t group_number;
|
||||
uint32_t name_offset;
|
||||
uint32_t name_length;
|
||||
} regex_pcre2_name_record;
|
||||
|
||||
/*
|
||||
* Trace collection has an independent bound from match materialization. The
|
||||
* byte cap covers the fixed event records plus copied mark bytes.
|
||||
*/
|
||||
typedef struct regex_pcre2_trace_limits {
|
||||
uint32_t maximum_events;
|
||||
uint32_t maximum_trace_bytes;
|
||||
uint32_t match_limit;
|
||||
uint32_t depth_limit;
|
||||
uint32_t heap_limit_kib;
|
||||
} regex_pcre2_trace_limits;
|
||||
|
||||
/*
|
||||
* Every field except the mark slice is copied directly from a PCRE2 callout
|
||||
* block. Positions and lengths are offsets in the 8-bit UTF-8 pattern or
|
||||
* subject supplied to the engine.
|
||||
*/
|
||||
typedef struct regex_pcre2_trace_event {
|
||||
uint32_t callout_number;
|
||||
uint32_t pattern_position_byte;
|
||||
uint32_t next_item_length_byte;
|
||||
uint32_t subject_position_byte;
|
||||
uint32_t capture_top;
|
||||
uint32_t capture_last;
|
||||
uint32_t mark_offset;
|
||||
uint32_t mark_length;
|
||||
} regex_pcre2_trace_event;
|
||||
|
||||
/*
|
||||
* A zero status means that a bounded trace was returned. native_match_status
|
||||
* retains pcre2_match()'s exact result, including PCRE2_ERROR_CALLOUT when the
|
||||
* bridge deliberately stopped at a trace cap. last_complete_event is
|
||||
* UINT32_MAX when no complete event was copied.
|
||||
*/
|
||||
typedef struct regex_pcre2_trace_result {
|
||||
int32_t status;
|
||||
uint32_t error_phase;
|
||||
uint32_t error_offset;
|
||||
int32_t native_match_status;
|
||||
uint32_t effective_options;
|
||||
uint32_t event_count;
|
||||
uint32_t total_event_count;
|
||||
uint32_t mark_bytes_length;
|
||||
uint32_t trace_bytes_length;
|
||||
uint32_t events_truncated;
|
||||
uint32_t marks_truncated;
|
||||
uint32_t last_complete_event;
|
||||
uint32_t matched;
|
||||
} regex_pcre2_trace_result;
|
||||
|
||||
/*
|
||||
* This fixed 60-byte record contains no native pointers. `status` is zero on
|
||||
* success. Otherwise `error_phase` distinguishes bridge validation, compile,
|
||||
* match and replacement errors; `error_offset` is a UTF-8 byte offset when the
|
||||
* phase provides one.
|
||||
*/
|
||||
typedef struct regex_pcre2_run_result {
|
||||
int32_t status;
|
||||
uint32_t error_phase;
|
||||
uint32_t error_offset;
|
||||
uint32_t capture_count;
|
||||
uint32_t name_count;
|
||||
uint32_t match_count;
|
||||
uint32_t record_count;
|
||||
uint32_t name_record_count;
|
||||
uint32_t name_bytes_length;
|
||||
uint32_t effective_options;
|
||||
uint32_t results_truncated;
|
||||
uint32_t names_truncated;
|
||||
uint32_t output_length;
|
||||
uint32_t substitution_count;
|
||||
uint32_t output_truncated;
|
||||
} regex_pcre2_run_result;
|
||||
|
||||
uint32_t regex_pcre2_bridge_abi_version(void);
|
||||
uint32_t regex_pcre2_config_flags(void);
|
||||
const uint8_t *regex_pcre2_version(void);
|
||||
|
||||
int32_t regex_pcre2_error_message(int32_t error_code,
|
||||
uint8_t *output,
|
||||
uint32_t output_capacity);
|
||||
|
||||
int32_t regex_pcre2_compile_probe(const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
uint32_t application_flags,
|
||||
regex_pcre2_compile_result *result);
|
||||
|
||||
int32_t regex_pcre2_execute(
|
||||
const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
const uint8_t *subject,
|
||||
uint32_t subject_length,
|
||||
uint32_t application_flags,
|
||||
const regex_pcre2_limits *limits,
|
||||
regex_pcre2_match_record *records,
|
||||
uint32_t record_capacity,
|
||||
regex_pcre2_name_record *name_records,
|
||||
uint32_t name_record_capacity,
|
||||
uint8_t *name_bytes,
|
||||
uint32_t name_bytes_capacity,
|
||||
regex_pcre2_run_result *result);
|
||||
|
||||
int32_t regex_pcre2_substitute(
|
||||
const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
const uint8_t *subject,
|
||||
uint32_t subject_length,
|
||||
const uint8_t *replacement,
|
||||
uint32_t replacement_length,
|
||||
uint32_t application_flags,
|
||||
const regex_pcre2_limits *limits,
|
||||
regex_pcre2_match_record *records,
|
||||
uint32_t record_capacity,
|
||||
regex_pcre2_name_record *name_records,
|
||||
uint32_t name_record_capacity,
|
||||
uint8_t *name_bytes,
|
||||
uint32_t name_bytes_capacity,
|
||||
/* `output` must have output_capacity + 1 writable bytes for PCRE2's NUL. */
|
||||
uint8_t *output,
|
||||
uint32_t output_capacity,
|
||||
regex_pcre2_run_result *result);
|
||||
|
||||
int32_t regex_pcre2_trace(
|
||||
const uint8_t *pattern,
|
||||
uint32_t pattern_length,
|
||||
const uint8_t *subject,
|
||||
uint32_t subject_length,
|
||||
uint32_t application_flags,
|
||||
const regex_pcre2_trace_limits *limits,
|
||||
regex_pcre2_trace_event *events,
|
||||
uint32_t event_capacity,
|
||||
uint8_t *mark_bytes,
|
||||
uint32_t mark_bytes_capacity,
|
||||
regex_pcre2_trace_result *result);
|
||||
|
||||
int32_t regex_pcre2_self_test(void);
|
||||
|
||||
#endif
|
||||
@@ -12,6 +12,7 @@ export default tseslint.config(
|
||||
"coverage",
|
||||
"test-results",
|
||||
"playwright-report",
|
||||
".engine-build",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "regex-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "regex-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"fflate": "0.8.3",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
},
|
||||
@@ -35,7 +36,6 @@
|
||||
"eslint": "10.7.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"fflate": "0.8.3",
|
||||
"globals": "17.7.0",
|
||||
"jsdom": "29.1.1",
|
||||
"prettier": "3.9.5",
|
||||
@@ -2442,7 +2442,6 @@
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "regex-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -32,6 +32,9 @@
|
||||
"test:conformance": "vitest run tests/conformance",
|
||||
"test:browser": "playwright test",
|
||||
"engines:build": "node scripts/build-engines.mjs",
|
||||
"engines:pcre2:build": "node scripts/build-engines.mjs --pcre2",
|
||||
"engines:pcre2:install": "node scripts/install-pcre2-engine.mjs",
|
||||
"engines:pcre2:verify": "node scripts/verify-engine-assets.mjs --pcre2-pack .engine-build/pcre2",
|
||||
"engines:verify": "node scripts/verify-engine-assets.mjs",
|
||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||
@@ -51,6 +54,7 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@eslint-community/regexpp": "4.12.2",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"fflate": "0.8.3",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
},
|
||||
@@ -68,7 +72,6 @@
|
||||
"eslint": "10.7.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"fflate": "0.8.3",
|
||||
"globals": "17.7.0",
|
||||
"jsdom": "29.1.1",
|
||||
"prettier": "3.9.5",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Engine assets
|
||||
|
||||
Regex Tools 0.1.0 executes ECMAScript through the browser's native `RegExp`
|
||||
engine. It therefore ships no external engine binary or WebAssembly pack.
|
||||
Regex Tools executes ECMAScript through the browser's native `RegExp` engine.
|
||||
It also ships the declared, self-hosted PCRE2 10.47 WebAssembly pack in
|
||||
`pcre2/`.
|
||||
|
||||
Future flavour packs will be built from pinned, documented open-source
|
||||
revisions and placed in versioned subdirectories here.
|
||||
The PCRE2 pack is built from pinned, documented open-source code and
|
||||
toolchain revisions. `engine-metadata.json`, `SHA256SUMS` and `LICENSE.txt`
|
||||
travel with the runtime files and are verified by the release gate.
|
||||
|
||||
104
public/engines/pcre2/LICENSE.txt
Normal file
104
public/engines/pcre2/LICENSE.txt
Normal file
@@ -0,0 +1,104 @@
|
||||
PCRE2 Licence
|
||||
=============
|
||||
|
||||
| SPDX-License-Identifier: | BSD-3-Clause WITH PCRE2-exception |
|
||||
|---------|-------|
|
||||
|
||||
PCRE2 is a library of functions to support regular expressions whose syntax
|
||||
and semantics are as close as possible to those of the Perl 5 language.
|
||||
|
||||
Releases 10.00 and above of PCRE2 are distributed under the terms of the "BSD"
|
||||
licence, as specified below, with one exemption for certain binary
|
||||
redistributions. The documentation for PCRE2, supplied in the "doc" directory,
|
||||
is distributed under the same terms as the software itself. The data in the
|
||||
testdata directory is not copyrighted and is in the public domain.
|
||||
|
||||
The basic library functions are written in C and are freestanding. Also
|
||||
included in the distribution is a just-in-time compiler that can be used to
|
||||
optimize pattern matching. This is an optional feature that can be omitted when
|
||||
the library is built. The just-in-time compiler is separately licensed under the
|
||||
"2-clause BSD" licence.
|
||||
|
||||
|
||||
COPYRIGHT
|
||||
---------
|
||||
|
||||
### The basic library functions
|
||||
|
||||
Written by: Philip Hazel
|
||||
Email local part: Philip.Hazel
|
||||
Email domain: gmail.com
|
||||
|
||||
Retired from University of Cambridge Computing Service,
|
||||
Cambridge, England.
|
||||
|
||||
Copyright (c) 1997-2007 University of Cambridge
|
||||
Copyright (c) 2007-2024 Philip Hazel
|
||||
All rights reserved.
|
||||
|
||||
### PCRE2 Just-In-Time compilation support
|
||||
|
||||
Written by: Zoltan Herczeg
|
||||
Email local part: hzmester
|
||||
Email domain: freemail.hu
|
||||
|
||||
Copyright (c) 2010-2024 Zoltan Herczeg
|
||||
All rights reserved.
|
||||
|
||||
### Stack-less Just-In-Time compiler
|
||||
|
||||
Written by: Zoltan Herczeg
|
||||
Email local part: hzmester
|
||||
Email domain: freemail.hu
|
||||
|
||||
Copyright (c) 2009-2024 Zoltan Herczeg
|
||||
All rights reserved.
|
||||
|
||||
The code in the `deps/sljit` directory has its own LICENSE file.
|
||||
|
||||
### All other contributions
|
||||
|
||||
Many other contributors have participated in the authorship of PCRE2. As PCRE2
|
||||
has never required a Contributor Licensing Agreement, or other copyright
|
||||
assignment agreement, all contributions have copyright retained by each
|
||||
original contributor or their employer.
|
||||
|
||||
|
||||
THE "BSD" LICENCE
|
||||
-----------------
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notices,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notices, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the University of Cambridge nor the names of any
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
EXEMPTION FOR BINARY LIBRARY-LIKE PACKAGES
|
||||
------------------------------------------
|
||||
|
||||
The second condition in the BSD licence (covering binary redistributions) does
|
||||
not apply all the way down a chain of software. If binary package A includes
|
||||
PCRE2, it must respect the condition, but if package B is software that
|
||||
includes package A, the condition is not imposed on package B unless it uses
|
||||
PCRE2 independently.
|
||||
4
public/engines/pcre2/SHA256SUMS
Normal file
4
public/engines/pcre2/SHA256SUMS
Normal file
@@ -0,0 +1,4 @@
|
||||
197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811 LICENSE.txt
|
||||
8c7f67ad85893fcc545c40d496abcc82d1009b76c9b7bcc7bd9ab92828852d76 engine-metadata.json
|
||||
d31a5f1e0839955ae8321430eafe65da8cbdef832a1245bb71ccc5bb0d4b6b9c pcre2.mjs
|
||||
cc7c4b2b0038e137c86d2037e73f2302e56432cb09bc082e4f35bba221dd1889 pcre2.wasm
|
||||
90
public/engines/pcre2/engine-metadata.json
Normal file
90
public/engines/pcre2/engine-metadata.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"engine": "pcre2",
|
||||
"engineVersion": "10.47 2025-10-21",
|
||||
"status": "production-runtime",
|
||||
"bridge": {
|
||||
"abiVersion": 3,
|
||||
"maximumPatternBytes": 1048576,
|
||||
"maximumSubjectBytes": 16777216,
|
||||
"maximumReplacementBytes": 262144,
|
||||
"maximumMatches": 10000,
|
||||
"maximumCaptureRows": 100000,
|
||||
"maximumCaptureGroups": 1000,
|
||||
"maximumTraceEvents": 50000,
|
||||
"maximumTraceBytes": 10485760,
|
||||
"maximumTraceMarkBytes": 1024,
|
||||
"sourceFiles": [
|
||||
{
|
||||
"path": "engines/pcre2/CMakeLists.txt",
|
||||
"sha256": "670397b070a908b414d4d8ffa7c0a9ed2549b9ee27815a90de5fc9622c6df8fa",
|
||||
"bytes": 2583
|
||||
},
|
||||
{
|
||||
"path": "engines/pcre2/pcre2_bridge.c",
|
||||
"sha256": "ab5929472d9981eb2d1cc635a4963a7243d5cbb9db6c5ba8460136d4c79b3246",
|
||||
"bytes": 32530
|
||||
},
|
||||
{
|
||||
"path": "engines/pcre2/pcre2_bridge.h",
|
||||
"sha256": "b8767c0c59229a92d092915fc7b862a22d088a7f184485e4d67e14d6071d7d1c",
|
||||
"bytes": 7388
|
||||
}
|
||||
],
|
||||
"exports": [
|
||||
"_free",
|
||||
"_malloc",
|
||||
"_regex_pcre2_bridge_abi_version",
|
||||
"_regex_pcre2_compile_probe",
|
||||
"_regex_pcre2_config_flags",
|
||||
"_regex_pcre2_error_message",
|
||||
"_regex_pcre2_execute",
|
||||
"_regex_pcre2_self_test",
|
||||
"_regex_pcre2_substitute",
|
||||
"_regex_pcre2_trace",
|
||||
"_regex_pcre2_version"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://github.com/PCRE2Project/pcre2.git",
|
||||
"tag": "pcre2-10.47",
|
||||
"tagObjectSha1": "cd007b4466798f66d479d1442a407099e7c40050",
|
||||
"commitSha1": "f454e231fe5006dd7ff8f4693fd2b8eb94333429",
|
||||
"treeSha1": "81a83a3552bd68d0ea7b7004f8bb6e7892f583ba",
|
||||
"license": "BSD-3-Clause WITH PCRE2-exception"
|
||||
},
|
||||
"toolchain": {
|
||||
"emscriptenVersion": "6.0.4",
|
||||
"emscriptenCompilerRevision": "fe5be6afdff43ad58860d821fcc8572a23f92d19",
|
||||
"emsdkCommitSha1": "224ec5f9f2f72f09f9ce0e26d66bae7dbd8b692f",
|
||||
"cmakeVersion": "4.3.4",
|
||||
"ninjaVersion": "1.13.2"
|
||||
},
|
||||
"configuration": {
|
||||
"codeUnitWidth": 8,
|
||||
"unicode": true,
|
||||
"jit": false,
|
||||
"threads": false,
|
||||
"filesystem": false,
|
||||
"initialMemoryBytes": 16777216,
|
||||
"maximumMemoryBytes": 268435456
|
||||
},
|
||||
"sourceDateEpoch": 1760997684,
|
||||
"files": [
|
||||
{
|
||||
"path": "LICENSE.txt",
|
||||
"sha256": "197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811",
|
||||
"bytes": 4011
|
||||
},
|
||||
{
|
||||
"path": "pcre2.mjs",
|
||||
"sha256": "d31a5f1e0839955ae8321430eafe65da8cbdef832a1245bb71ccc5bb0d4b6b9c",
|
||||
"bytes": 7513
|
||||
},
|
||||
{
|
||||
"path": "pcre2.wasm",
|
||||
"sha256": "cc7c4b2b0038e137c86d2037e73f2302e56432cb09bc082e4f35bba221dd1889",
|
||||
"bytes": 339494
|
||||
}
|
||||
]
|
||||
}
|
||||
2
public/engines/pcre2/pcre2.mjs
Normal file
2
public/engines/pcre2/pcre2.mjs
Normal file
File diff suppressed because one or more lines are too long
BIN
public/engines/pcre2/pcre2.wasm
Normal file
BIN
public/engines/pcre2/pcre2.wasm
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.regex-tools",
|
||||
"name": "Regex Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
buildPcre2Pack,
|
||||
parsePcre2BuildArguments,
|
||||
} from "./pcre2-engine-pack.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const options = parsePcre2BuildArguments(process.argv.slice(2));
|
||||
|
||||
if (options.mode === "help") {
|
||||
console.log(`Usage:
|
||||
node scripts/build-engines.mjs
|
||||
node scripts/build-engines.mjs --pcre2 --source-dir PATH --emcc PATH [--force]
|
||||
|
||||
The PCRE2 command is an explicit offline build gate. It verifies exact local
|
||||
source and compiler identities, writes .engine-build/pcre2, and never
|
||||
downloads or publishes an engine pack. Install the verified local pack with
|
||||
npm run engines:pcre2:install.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (options.mode === "pcre2") {
|
||||
const result = await buildPcre2Pack(options, root);
|
||||
console.log(
|
||||
`Built and verified staged PCRE2 ${result.metadata.engineVersion} pack at ${result.output}.`,
|
||||
);
|
||||
console.log(
|
||||
"The verified pack remains in private staging until the explicit local install command is run.",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
@@ -10,15 +39,16 @@ const engineDirectory = path.join(root, "public", "engines");
|
||||
const entries = (await readdir(engineDirectory)).sort();
|
||||
|
||||
if (
|
||||
packageJson.version !== "0.1.0" ||
|
||||
entries.length !== 1 ||
|
||||
entries[0] !== "README.md"
|
||||
typeof packageJson.version !== "string" ||
|
||||
entries.length !== 2 ||
|
||||
entries[0] !== "README.md" ||
|
||||
entries[1] !== "pcre2"
|
||||
) {
|
||||
throw new Error(
|
||||
"The ECMAScript release must contain only the documented native-browser engine placeholder.",
|
||||
"The production build requires the documented PCRE2 runtime pack.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Regex Tools 0.1.0 uses the native browser RegExp engine; no external engine pack needs building.",
|
||||
"The checked-in PCRE2 runtime pack is present; no release-time download or engine compilation is needed.",
|
||||
);
|
||||
|
||||
@@ -7,14 +7,23 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
|
||||
const outputPath = join(root, "public", "toolbox-app.json");
|
||||
const packagePath = join(root, "package.json");
|
||||
const applicationVersionPath = join(root, "src", "version.ts");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
const applicationVersionSource = await readFile(applicationVersionPath, "utf8");
|
||||
const applicationVersion =
|
||||
/^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec(
|
||||
applicationVersionSource,
|
||||
)?.[1];
|
||||
|
||||
if (source.version !== packageJson.version) {
|
||||
if (
|
||||
source.version !== packageJson.version ||
|
||||
applicationVersion !== packageJson.version
|
||||
) {
|
||||
throw new Error(
|
||||
`Manifest version ${source.version} differs from package version ${packageJson.version}`,
|
||||
`Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
16
scripts/install-pcre2-engine.mjs
Normal file
16
scripts/install-pcre2-engine.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { installPcre2Pack } from "./pcre2-engine-pack.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const arguments_ = process.argv.slice(2);
|
||||
if (arguments_.length > 1) {
|
||||
throw new Error(
|
||||
"Usage: node scripts/install-pcre2-engine.mjs [.engine-build/pcre2]",
|
||||
);
|
||||
}
|
||||
const source = path.resolve(root, arguments_[0] ?? ".engine-build/pcre2");
|
||||
const result = await installPcre2Pack(source, root);
|
||||
console.log(
|
||||
`Installed verified PCRE2 ${result.metadata.engineVersion} runtime assets at ${result.output}.`,
|
||||
);
|
||||
@@ -18,6 +18,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
||||
const gplVersion3TextSha256 =
|
||||
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
|
||||
const expectedApplicationVersion = "0.2.0";
|
||||
const requiredRootFiles = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
@@ -332,13 +333,27 @@ async function collectReleaseEntries() {
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (
|
||||
packageJson.version !== "0.1.0" ||
|
||||
packageJson.version !== expectedApplicationVersion ||
|
||||
packageJson.license !== "GPL-3.0-or-later" ||
|
||||
packageJson.repository?.url !==
|
||||
"git+https://git.add-ideas.de/zemion/regex-tools.git"
|
||||
) {
|
||||
throw new Error("Package version, licence or source identity drifted.");
|
||||
}
|
||||
const sourceIdentity = await readFile(path.join(root, "SOURCE.md"), "utf8");
|
||||
if (
|
||||
!sourceIdentity.includes(
|
||||
`- Release version: \`${expectedApplicationVersion}\``,
|
||||
) ||
|
||||
!sourceIdentity.includes(
|
||||
`- Release tag: \`v${expectedApplicationVersion}\``,
|
||||
) ||
|
||||
!sourceIdentity.includes(
|
||||
"- Repository: <https://git.add-ideas.de/zemion/regex-tools>",
|
||||
)
|
||||
) {
|
||||
throw new Error("SOURCE.md release identity drifted.");
|
||||
}
|
||||
const licence = await readFile(path.join(root, "LICENSE"), "utf8");
|
||||
if (
|
||||
!licence.includes("GNU GENERAL PUBLIC LICENSE") ||
|
||||
@@ -371,6 +386,14 @@ async function collectReleaseEntries() {
|
||||
"CHANGELOG.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"engines/README.md",
|
||||
"engines/pcre2/LICENSE.txt",
|
||||
"engines/pcre2/SHA256SUMS",
|
||||
"engines/pcre2/engine-metadata.json",
|
||||
"engines/pcre2/pcre2.mjs",
|
||||
"engines/pcre2/pcre2.wasm",
|
||||
"LICENSES/Emscripten-MIT.txt",
|
||||
"LICENSES/PCRE2.txt",
|
||||
"LICENSES/build/rolldown-1.1.5/LICENSE",
|
||||
"LICENSES/build/vite-8.1.5/LICENSE.md",
|
||||
]) {
|
||||
|
||||
110
scripts/pcre2-codegen-golden.test.ts
Normal file
110
scripts/pcre2-codegen-golden.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
generatePcre2C,
|
||||
type Pcre2CGenerationRequest,
|
||||
} from "../src/regex/codegen/pcre2-c";
|
||||
|
||||
function request(operation: "match" | "replace"): Pcre2CGenerationRequest {
|
||||
return {
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
|
||||
operation,
|
||||
pattern: String.raw`(?<word>\p{L}+)-(\d+)`,
|
||||
flags: ["g"],
|
||||
options: {
|
||||
matchLimit: 1_000_000,
|
||||
depthLimit: 1_000,
|
||||
heapLimitKib: 32_768,
|
||||
},
|
||||
subject: "x Grüße-42 y alpha-7",
|
||||
scanAll: true,
|
||||
...(operation === "replace" ? { replacement: "$2:$<word>" } : {}),
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
maximumOutputBytes: 4_096,
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
const exactToolchainPrefix = process.env.PCRE2_CODEGEN_PREFIX;
|
||||
const compileWithExactPcre2 = exactToolchainPrefix ? it : it.skip;
|
||||
|
||||
describe("PCRE2 C code-generation golden", () => {
|
||||
it("retains a reviewed deterministic source identity", () => {
|
||||
expect(sha256(generatePcre2C(request("match")).source)).toBe(
|
||||
"22b91d578a449b0ed5c30e6eadad30f19a2bcb26ac9db355c8ec7a69328675da",
|
||||
);
|
||||
expect(sha256(generatePcre2C(request("replace")).source)).toBe(
|
||||
"ab90a0d9831ea7b7145aa0072ac352b42baf9a795319d81f531b0f341b04a636",
|
||||
);
|
||||
});
|
||||
|
||||
compileWithExactPcre2(
|
||||
"compiles and executes against the explicitly supplied PCRE2 10.47 C toolchain",
|
||||
async () => {
|
||||
if (!exactToolchainPrefix) return;
|
||||
const header = path.join(exactToolchainPrefix, "include", "pcre2.h");
|
||||
const library =
|
||||
process.env.PCRE2_CODEGEN_LIBRARY ??
|
||||
path.join(exactToolchainPrefix, "lib64", "libpcre2-8.a");
|
||||
await expect(readFile(header)).resolves.toBeInstanceOf(Buffer);
|
||||
await expect(readFile(library)).resolves.toBeInstanceOf(Buffer);
|
||||
const directory = await mkdtemp(
|
||||
path.join(tmpdir(), "regex-tools-codegen-"),
|
||||
);
|
||||
try {
|
||||
for (const operation of ["match", "replace"] as const) {
|
||||
const generated = generatePcre2C(request(operation));
|
||||
const source = path.join(directory, generated.fileName);
|
||||
const executable = path.join(directory, `pcre2-${operation}`);
|
||||
await writeFile(source, generated.source);
|
||||
const compiled = spawnSync(
|
||||
process.env.CC ?? "cc",
|
||||
[
|
||||
"-std=c17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
`-I${path.join(exactToolchainPrefix, "include")}`,
|
||||
source,
|
||||
library,
|
||||
"-o",
|
||||
executable,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(
|
||||
`${compiled.stdout}${compiled.stderr}`,
|
||||
`${operation} fixture did not compile`,
|
||||
).toBe("");
|
||||
expect(compiled.status).toBe(0);
|
||||
const executed = spawnSync(executable, [], { encoding: "utf8" });
|
||||
expect(
|
||||
executed.status,
|
||||
`${operation} fixture failed: ${executed.stdout}${executed.stderr}`,
|
||||
).toBe(0);
|
||||
if (operation === "match") {
|
||||
expect(executed.stdout).toContain("match 1: UTF-8 bytes 2..12");
|
||||
expect(executed.stdout).toContain("group 1 / word");
|
||||
expect(executed.stdout).toContain(
|
||||
"completed: 2 match(es), 4 capture row(s)",
|
||||
);
|
||||
} else {
|
||||
expect(executed.stdout).toBe("x 42:Grüße y 7:alpha");
|
||||
expect(executed.stderr).toContain("completed: 2 substitution(s)");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
1018
scripts/pcre2-engine-pack.mjs
Normal file
1018
scripts/pcre2-engine-pack.mjs
Normal file
File diff suppressed because it is too large
Load Diff
89
scripts/pcre2-engine-pack.test.ts
Normal file
89
scripts/pcre2-engine-pack.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
PCRE2_LOCK,
|
||||
parsePcre2BuildArguments,
|
||||
verifyPcre2Pack,
|
||||
} from "./pcre2-engine-pack.mjs";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("PCRE2 engine-pack gate", () => {
|
||||
it("keeps the default build on the checked-in production-assets path", () => {
|
||||
expect(parsePcre2BuildArguments([])).toEqual({ mode: "ecmascript" });
|
||||
});
|
||||
|
||||
it("requires explicit offline source and compiler paths", () => {
|
||||
expect(() => parsePcre2BuildArguments(["--pcre2"])).toThrow(
|
||||
"requires both --source-dir and --emcc",
|
||||
);
|
||||
expect(() =>
|
||||
parsePcre2BuildArguments([
|
||||
"--source-dir",
|
||||
"/source",
|
||||
"--emcc",
|
||||
"/compiler",
|
||||
]),
|
||||
).toThrow("explicit --pcre2 gate");
|
||||
});
|
||||
|
||||
it("parses the exact staged-build contract", () => {
|
||||
expect(
|
||||
parsePcre2BuildArguments([
|
||||
"--pcre2",
|
||||
"--source-dir",
|
||||
"/source",
|
||||
"--emcc",
|
||||
"/compiler",
|
||||
"--force",
|
||||
]),
|
||||
).toEqual({
|
||||
mode: "pcre2",
|
||||
sourceDirectory: "/source",
|
||||
emccFile: "/compiler",
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["--unknown"],
|
||||
["--pcre2", "--pcre2"],
|
||||
["--pcre2", "--source-dir"],
|
||||
["--pcre2", "--force", "--force"],
|
||||
])("rejects ambiguous arguments %j", (...arguments_) => {
|
||||
expect(() => parsePcre2BuildArguments(arguments_)).toThrow();
|
||||
});
|
||||
|
||||
it("pins source, signed-tag object and compiler identities", () => {
|
||||
expect(PCRE2_LOCK.source.tag).toBe("pcre2-10.47");
|
||||
expect(PCRE2_LOCK.source.tagObject).toHaveLength(40);
|
||||
expect(PCRE2_LOCK.source.commit).toHaveLength(40);
|
||||
expect(PCRE2_LOCK.source.tree).toHaveLength(40);
|
||||
expect(PCRE2_LOCK.emscripten.version).toBe("6.0.4");
|
||||
expect(PCRE2_LOCK.emscripten.emccSha256).toHaveLength(64);
|
||||
expect(PCRE2_LOCK.bridgeAbi).toBe(3);
|
||||
expect(PCRE2_LOCK.maximumSubjectBytes).toBe(16 * 1024 * 1024);
|
||||
expect(PCRE2_LOCK.maximumMatches).toBe(10_000);
|
||||
});
|
||||
|
||||
it("rejects an incomplete staged pack before attempting to load it", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "regex-pcre2-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
|
||||
await expect(verifyPcre2Pack(directory, process.cwd())).rejects.toThrow(
|
||||
"must contain only",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const mediaTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".mjs", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".wasm", "application/wasm"],
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { verifyPcre2Pack } from "./pcre2-engine-pack.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const arguments_ = process.argv.slice(2);
|
||||
|
||||
if (arguments_.length > 0) {
|
||||
if (arguments_.length !== 2 || arguments_[0] !== "--pcre2-pack") {
|
||||
throw new Error(
|
||||
"Usage: node scripts/verify-engine-assets.mjs [--pcre2-pack PATH]",
|
||||
);
|
||||
}
|
||||
const pack = path.resolve(root, arguments_[1]);
|
||||
const metadata = await verifyPcre2Pack(pack, root);
|
||||
console.log(
|
||||
`Verified staged PCRE2 ${metadata.engineVersion} pack and WebAssembly bridge smoke test.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const engineDirectory = path.join(root, "dist", "engines");
|
||||
const details = await lstat(engineDirectory);
|
||||
if (details.isSymbolicLink() || !details.isDirectory()) {
|
||||
@@ -14,21 +31,21 @@ const entries = (await readdir(engineDirectory, { withFileTypes: true })).sort(
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
||||
);
|
||||
if (
|
||||
entries.length !== 1 ||
|
||||
entries.length !== 2 ||
|
||||
!entries[0]?.isFile() ||
|
||||
entries[0].name !== "README.md"
|
||||
entries[0].name !== "README.md" ||
|
||||
!entries[1]?.isDirectory() ||
|
||||
entries[1].name !== "pcre2"
|
||||
) {
|
||||
throw new Error(
|
||||
"Regex Tools 0.1.0 must not ship an undeclared external engine pack.",
|
||||
"The production build must contain only README.md and the declared PCRE2 pack.",
|
||||
);
|
||||
}
|
||||
|
||||
const notice = await readFile(path.join(engineDirectory, "README.md"), "utf8");
|
||||
if (
|
||||
!notice.includes("native `RegExp`") ||
|
||||
!notice.includes("no external engine")
|
||||
) {
|
||||
if (!notice.includes("native `RegExp`") || !notice.includes("PCRE2 10.47")) {
|
||||
throw new Error("The engine asset notice does not describe this release.");
|
||||
}
|
||||
await verifyPcre2Pack(path.join(engineDirectory, "pcre2"), root);
|
||||
|
||||
console.log("Verified ECMAScript-only engine assets (no WebAssembly pack).");
|
||||
console.log("Verified native ECMAScript and bundled PCRE2 engine assets.");
|
||||
|
||||
521
src/components/AnalysisPanel.css
Normal file
521
src/components/AnalysisPanel.css
Normal file
@@ -0,0 +1,521 @@
|
||||
.analysis-dialog {
|
||||
width: min(90rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.analysis-panel {
|
||||
--analysis-high: var(--toolbox-danger);
|
||||
--analysis-warning: var(--regex-warning);
|
||||
--analysis-notice: var(--toolbox-accent);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.analysis-view-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.65rem 0.75rem 0;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.analysis-view-tabs button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--toolbox-radius) * 0.7)
|
||||
calc(var(--toolbox-radius) * 0.7) 0 0;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: transparent;
|
||||
color: var(--toolbox-muted);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.analysis-view-tabs button[aria-selected="true"] {
|
||||
border-color: var(--toolbox-border);
|
||||
border-bottom-color: var(--toolbox-surface);
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-section,
|
||||
.analysis-result-block {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.82);
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.analysis-section > header,
|
||||
.analysis-result-block > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.analysis-section > header h3,
|
||||
.analysis-result-block > header h3 {
|
||||
margin: 0.12rem 0 0;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.analysis-section > header > span,
|
||||
.analysis-result-block > header > span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.analysis-summary,
|
||||
.analysis-advisory,
|
||||
.analysis-stop-reason,
|
||||
.analysis-error,
|
||||
.analysis-unavailable {
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.75rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.79rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.analysis-advisory,
|
||||
.analysis-stop-reason {
|
||||
border-left: 3px solid var(--toolbox-accent);
|
||||
background: var(--toolbox-accent-soft);
|
||||
}
|
||||
|
||||
.analysis-unavailable,
|
||||
.analysis-error {
|
||||
margin: 0.75rem 0.75rem 0;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-danger) 45%, var(--toolbox-border));
|
||||
border-radius: calc(var(--toolbox-radius) * 0.72);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 8%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: color-mix(in srgb, var(--toolbox-danger) 78%, var(--toolbox-text));
|
||||
}
|
||||
|
||||
.analysis-status {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.75rem 0.75rem 0;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.72);
|
||||
background: var(--toolbox-surface-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.analysis-status > span {
|
||||
border-radius: 99rem;
|
||||
padding: 0.2rem 0.42rem;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.analysis-status > strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.analysis-progress {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.5rem 0.75rem 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.analysis-progress progress {
|
||||
width: 100%;
|
||||
accent-color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.analysis-findings {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-finding {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-left-width: 4px;
|
||||
border-radius: calc(var(--toolbox-radius) * 0.72);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.analysis-finding.is-high {
|
||||
border-left-color: var(--analysis-high);
|
||||
}
|
||||
|
||||
.analysis-finding.is-warning {
|
||||
border-left-color: var(--analysis-warning);
|
||||
}
|
||||
|
||||
.analysis-finding.is-notice {
|
||||
border-left-color: var(--analysis-notice);
|
||||
}
|
||||
|
||||
.analysis-finding-title {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
padding: 0.65rem;
|
||||
background: transparent;
|
||||
color: var(--toolbox-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.analysis-finding-title:hover {
|
||||
background: var(--toolbox-accent-soft);
|
||||
}
|
||||
|
||||
.analysis-finding-title > span:last-child {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.analysis-finding-title small {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.analysis-risk-level,
|
||||
.analysis-run-badge {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 99rem;
|
||||
padding: 0.2rem 0.42rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.analysis-risk-level.is-high {
|
||||
color: var(--analysis-high);
|
||||
}
|
||||
|
||||
.analysis-risk-level.is-warning {
|
||||
color: color-mix(in srgb, var(--analysis-warning) 78%, var(--toolbox-text));
|
||||
}
|
||||
|
||||
.analysis-risk-level.is-notice {
|
||||
color: var(--analysis-notice);
|
||||
}
|
||||
|
||||
.analysis-run-badge.is-complete {
|
||||
color: color-mix(in srgb, var(--regex-mint) 72%, var(--toolbox-text));
|
||||
}
|
||||
|
||||
.analysis-run-badge.is-timeout,
|
||||
.analysis-run-badge.is-error {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.analysis-run-badge.is-cancelled,
|
||||
.analysis-run-badge.is-wall-time-limit {
|
||||
color: var(--analysis-warning);
|
||||
}
|
||||
|
||||
.analysis-finding > p {
|
||||
padding: 0 0.65rem 0.65rem;
|
||||
color: var(--toolbox-text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.analysis-finding dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.analysis-finding dl > div {
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.analysis-finding dt,
|
||||
.analysis-result-meta dt,
|
||||
.analysis-input-summary dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.analysis-finding dd,
|
||||
.analysis-result-meta dd,
|
||||
.analysis-input-summary dd {
|
||||
margin: 0.2rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.analysis-details {
|
||||
margin: 0 0.75rem 0.75rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.analysis-details li,
|
||||
.analysis-limitations li {
|
||||
margin-block: 0.35rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.analysis-growth-text-controls,
|
||||
.analysis-number-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-growth-text-controls > label,
|
||||
.analysis-number-controls > label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.analysis-growth-text-controls label > span,
|
||||
.analysis-number-controls label > span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.analysis-run-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-result-block {
|
||||
margin: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-result-meta,
|
||||
.analysis-input-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
margin: 0;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-result-meta > div,
|
||||
.analysis-input-summary > div {
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.analysis-table-scroll {
|
||||
overflow: auto;
|
||||
border-block: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.analysis-table-scroll table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
|
||||
.analysis-table-scroll th,
|
||||
.analysis-table-scroll td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.analysis-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-chart {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.72);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.analysis-chart figcaption {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.analysis-chart figcaption strong {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.analysis-chart figcaption span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.analysis-chart svg {
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.analysis-chart-axis {
|
||||
fill: none;
|
||||
stroke: var(--toolbox-border);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.analysis-chart-line {
|
||||
fill: none;
|
||||
stroke: var(--toolbox-accent);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.analysis-chart circle {
|
||||
fill: var(--toolbox-surface);
|
||||
stroke: var(--toolbox-accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.analysis-empty-chart {
|
||||
display: grid;
|
||||
min-height: 10rem;
|
||||
place-items: center;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.analysis-outcome-plot {
|
||||
display: flex;
|
||||
min-height: 10rem;
|
||||
align-items: end;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.analysis-outcome-plot > span {
|
||||
min-width: 0.35rem;
|
||||
max-width: 2rem;
|
||||
height: 45%;
|
||||
flex: 1;
|
||||
border-radius: 0.2rem 0.2rem 0 0;
|
||||
background: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.analysis-outcome-plot > span.is-match {
|
||||
height: 88%;
|
||||
background: var(--regex-mint);
|
||||
}
|
||||
|
||||
.analysis-outcome-plot > span.is-no-match {
|
||||
background: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.analysis-outcome-plot > span.is-timeout {
|
||||
height: 100%;
|
||||
background: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.analysis-outcome-plot > span.is-crash,
|
||||
.analysis-outcome-plot > span.is-compile-error {
|
||||
height: 100%;
|
||||
background: var(--analysis-warning);
|
||||
}
|
||||
|
||||
.analysis-outcome-key {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.7rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.analysis-outcome-key span::before {
|
||||
display: inline-block;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: 0.15rem;
|
||||
background: var(--toolbox-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.analysis-outcome-key .is-match::before {
|
||||
background: var(--regex-mint);
|
||||
}
|
||||
|
||||
.analysis-outcome-key .is-no-match::before {
|
||||
background: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.analysis-outcome-key .is-timeout::before {
|
||||
background: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.analysis-outcome-key .is-crash::before {
|
||||
background: var(--analysis-warning);
|
||||
}
|
||||
|
||||
.analysis-limitations {
|
||||
margin: 0;
|
||||
padding: 0 1.6rem 0.75rem 2rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.analysis-section > header,
|
||||
.analysis-result-block > header {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.analysis-section > header > span,
|
||||
.analysis-result-block > header > span {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.analysis-status {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.analysis-status > strong {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
248
src/components/AnalysisPanel.test.tsx
Normal file
248
src/components/AnalysisPanel.test.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AnalysisWorkerClient } from "../regex/analysis/AnalysisSupervisor";
|
||||
import type {
|
||||
BenchmarkWorkerSample,
|
||||
GrowthWorkerSample,
|
||||
} from "../regex/analysis/analysis.types";
|
||||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||||
import { EcmaScriptSyntaxProvider } from "../regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
|
||||
const provider = new EcmaScriptSyntaxProvider();
|
||||
|
||||
function benchmarkSample(milliseconds = 1): BenchmarkWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "dg",
|
||||
subjectBytes: 5,
|
||||
subjectUtf16: 5,
|
||||
compileMs: milliseconds,
|
||||
firstMatchMs: milliseconds + 1,
|
||||
allMatchesMs: milliseconds + 2,
|
||||
replacementMs: milliseconds + 3,
|
||||
throughputBytesPerSecond: 1024 * 1024,
|
||||
matchCount: 1,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function growthSample(executionMs: number): GrowthWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "d",
|
||||
subjectBytes: 5,
|
||||
subjectUtf16: 5,
|
||||
executionMs,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function worker(
|
||||
overrides: Partial<AnalysisWorkerClient> = {},
|
||||
): AnalysisWorkerClient {
|
||||
return {
|
||||
identity: vi.fn().mockResolvedValue({
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Fixture Browser 1",
|
||||
runtimeVersion: "Fixture Browser 1",
|
||||
nativeOffsetUnit: "utf16",
|
||||
}),
|
||||
benchmarkSample: vi.fn().mockResolvedValue(benchmarkSample()),
|
||||
growthProbe: vi.fn().mockResolvedValue(growthSample(1)),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPanel(
|
||||
overrides: Partial<Parameters<typeof AnalysisPanel>[0]> = {},
|
||||
) {
|
||||
const pattern = overrides.pattern ?? "(a+)+$";
|
||||
const flavour = overrides.flavour ?? "ecmascript";
|
||||
const syntax =
|
||||
overrides.syntax ??
|
||||
(await provider.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "2025",
|
||||
pattern,
|
||||
flags: [],
|
||||
options: {},
|
||||
}));
|
||||
const currentWorker = worker();
|
||||
const rendered = render(
|
||||
<AnalysisPanel
|
||||
active
|
||||
flavour={flavour}
|
||||
pattern={pattern}
|
||||
flags={[]}
|
||||
subject="aaaa!"
|
||||
replacement="x"
|
||||
scanAll={false}
|
||||
syntax={syntax}
|
||||
createSupervisor={() => currentWorker}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { ...rendered, currentWorker, syntax };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("AnalysisPanel", () => {
|
||||
it("shows flavour-scoped advisory static findings and selects their source range", async () => {
|
||||
const onSelectPatternRange = vi.fn();
|
||||
await renderPanel({ onSelectPatternRange });
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Potential risk findings" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Potential nested-quantifier risk")).toBeVisible();
|
||||
expect(screen.getByText(/potential risks found/u)).toBeVisible();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: /Potential nested-quantifier risk/u,
|
||||
}),
|
||||
);
|
||||
expect(onSelectPatternRange).toHaveBeenCalledWith({
|
||||
startUtf16: 0,
|
||||
endUtf16: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs bounded cold and warm benchmark samples and renders p95", async () => {
|
||||
const { currentWorker } = await renderPanel();
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Benchmark" }));
|
||||
await userEvent.clear(
|
||||
screen.getByRole("spinbutton", { name: "Measured samples" }),
|
||||
);
|
||||
await userEvent.type(
|
||||
screen.getByRole("spinbutton", { name: "Measured samples" }),
|
||||
"3",
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Run bounded benchmark" }),
|
||||
);
|
||||
|
||||
const table = await screen.findByRole("table", {
|
||||
name: "Cold and warm benchmark metrics",
|
||||
});
|
||||
expect(table).toHaveTextContent("Warm p95");
|
||||
expect(table).toHaveTextContent("Compile");
|
||||
expect(screen.getByText(/Fixture Browser 1/u)).toBeVisible();
|
||||
expect(screen.getAllByText(/3 measured/u)).toHaveLength(2);
|
||||
expect(currentWorker.identity).toHaveBeenCalled();
|
||||
expect(currentWorker.benchmarkSample).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it("renders separate timing and outcome plots for bounded growth", async () => {
|
||||
const samples = [growthSample(0.5), growthSample(10)];
|
||||
const currentWorker = worker({
|
||||
growthProbe: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(samples.shift()!)),
|
||||
});
|
||||
await renderPanel({ createSupervisor: () => currentWorker });
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Run bounded growth probe" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("img", {
|
||||
name: /Execution-time growth plot/u,
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("img", { name: /Outcome plot with 2 samples/u }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("Observed disproportionate growth")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("table", { name: "Dynamic growth samples" }),
|
||||
).toHaveTextContent("Normalized growth");
|
||||
});
|
||||
|
||||
it("does not apply ECMAScript analysis to PCRE2", async () => {
|
||||
const currentWorker = worker();
|
||||
await renderPanel({
|
||||
flavour: "pcre2",
|
||||
createSupervisor: () => currentWorker,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"supports ECMAScript 2025 only",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Run bounded growth probe" }),
|
||||
).toBeDisabled();
|
||||
expect(currentWorker.identity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never renders benchmark results against a changed subject", async () => {
|
||||
const rendered = await renderPanel();
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Benchmark" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Run bounded benchmark" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("table", {
|
||||
name: "Cold and warm benchmark metrics",
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
rendered.rerender(
|
||||
<AnalysisPanel
|
||||
active
|
||||
flavour="ecmascript"
|
||||
pattern="(a+)+$"
|
||||
flags={[]}
|
||||
subject="changed subject"
|
||||
replacement="x"
|
||||
scanAll={false}
|
||||
syntax={rendered.syntax}
|
||||
createSupervisor={() => rendered.currentWorker}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("table", {
|
||||
name: "Cold and warm benchmark metrics",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("terminates the active worker when the user cancels", async () => {
|
||||
let rejectSample: ((reason: Error) => void) | undefined;
|
||||
const pending = new Promise<BenchmarkWorkerSample>((_resolve, reject) => {
|
||||
rejectSample = reject;
|
||||
});
|
||||
const currentWorker = worker({
|
||||
benchmarkSample: vi.fn().mockReturnValue(pending),
|
||||
cancel: vi.fn().mockImplementation(() => {
|
||||
rejectSample?.(
|
||||
new WorkerRequestError("cancelled", "fixture cancellation"),
|
||||
);
|
||||
}),
|
||||
});
|
||||
await renderPanel({ createSupervisor: () => currentWorker });
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Benchmark" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Run bounded benchmark" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Cancel benchmark" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(currentWorker.cancel).toHaveBeenCalled());
|
||||
expect(await screen.findAllByText(/Benchmark cancelled/u)).toHaveLength(2);
|
||||
expect(currentWorker.dispose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
1245
src/components/AnalysisPanel.tsx
Normal file
1245
src/components/AnalysisPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RegexExecutionResult } from "../regex/model/match";
|
||||
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
||||
import { CapabilityPanel } from "./CapabilityPanel";
|
||||
@@ -78,11 +79,66 @@ describe("CapabilityPanel", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Fixture engine")).toBeInTheDocument();
|
||||
expect(screen.getByText("123")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Not separately reported by this adapter"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("1", { selector: "dd" })).toBeInTheDocument();
|
||||
expect(screen.getByText("UTF-8 bytes")).toBeInTheDocument();
|
||||
expect(screen.getByText("fixture")).toBeInTheDocument();
|
||||
|
||||
const replacement = screen.getByText("Replacement").closest("div");
|
||||
expect(replacement).toHaveTextContent("Unavailable");
|
||||
const compilation = screen.getByText("Compilation").closest("div");
|
||||
expect(compilation).toHaveTextContent("Available");
|
||||
const generatedCases = screen.getByText("Generated cases").closest("div");
|
||||
expect(generatedCases).toHaveTextContent(
|
||||
"deterministic AST candidates, retained only after actual-engine verification",
|
||||
);
|
||||
const formatting = screen.getByText("Pattern formatting").closest("div");
|
||||
expect(formatting).toHaveTextContent(
|
||||
"grammar-backed literal/control escaping with mandatory exact-snapshot validation",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not advertise ECMAScript generation for the partial PCRE2 syntax provider", () => {
|
||||
render(
|
||||
<CapabilityPanel
|
||||
syntax={syntax}
|
||||
execution={{
|
||||
...execution,
|
||||
engine: {
|
||||
...execution.engine,
|
||||
flavour: "pcre2",
|
||||
engineName: "PCRE2",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generatedCases = screen.getByText("Generated cases").closest("div");
|
||||
expect(generatedCases).toHaveTextContent(
|
||||
"Unavailable — the partial PCRE2 provider does not expose a complete generation AST",
|
||||
);
|
||||
const formatting = screen.getByText("Pattern formatting").closest("div");
|
||||
expect(formatting).toHaveTextContent(
|
||||
"Unavailable — no complete PCRE2 grammar-backed formatter is implemented",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers an accessible close control when used as a modal panel", async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CapabilityPanel
|
||||
syntax={syntax}
|
||||
execution={execution}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Close capabilities" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { REGEXPP_VERSION, SYNTAX_PROFILE } from "../version";
|
||||
import { SYNTAX_PROFILE } from "../version";
|
||||
import type { RegexExecutionResult } from "../regex/model/match";
|
||||
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
||||
import type { RegexEngineCapabilities } from "../regex/model/flavour";
|
||||
@@ -35,23 +35,27 @@ function offsetLabel(
|
||||
export function CapabilityPanel({
|
||||
syntax,
|
||||
execution,
|
||||
onClose,
|
||||
}: {
|
||||
readonly syntax?: RegexSyntaxResult;
|
||||
readonly execution?: RegexExecutionResult;
|
||||
readonly onClose?: () => void;
|
||||
}) {
|
||||
const capabilities = execution?.engine.capabilities;
|
||||
const activeFlavour =
|
||||
execution?.engine.flavour ?? syntax?.root.support.flavour;
|
||||
const rows = [
|
||||
[
|
||||
"Flavour",
|
||||
execution?.engine.flavour ??
|
||||
syntax?.root.support.flavour ??
|
||||
"ECMAScript (awaiting workers)",
|
||||
"Awaiting flavour workers",
|
||||
],
|
||||
[
|
||||
"Syntax provider",
|
||||
syntax
|
||||
? `${syntax.provider.id} ${syntax.provider.version}`
|
||||
: `regexpp ${REGEXPP_VERSION} (awaiting syntax worker)`,
|
||||
: "Awaiting syntax worker result",
|
||||
],
|
||||
["Syntax profile", SYNTAX_PROFILE],
|
||||
[
|
||||
@@ -65,9 +69,20 @@ export function CapabilityPanel({
|
||||
execution?.engine.engineName ?? "Awaiting first engine result",
|
||||
],
|
||||
[
|
||||
"Engine/runtime version",
|
||||
"Engine version",
|
||||
execution?.engine.engineVersion ?? "Shown after first execution",
|
||||
],
|
||||
[
|
||||
"Runtime version",
|
||||
execution?.engine.runtimeVersion ??
|
||||
(execution
|
||||
? "Not separately reported by this adapter"
|
||||
: "Shown after first execution"),
|
||||
],
|
||||
[
|
||||
"Adapter version",
|
||||
execution?.engine.adapterVersion ?? "Shown after first execution",
|
||||
],
|
||||
["Native offsets", offsetLabel(execution?.engine.offsetUnit)],
|
||||
["Compilation", capability(capabilities, "compilation")],
|
||||
["Matching", capability(capabilities, "matching")],
|
||||
@@ -90,6 +105,38 @@ export function CapabilityPanel({
|
||||
),
|
||||
],
|
||||
["Benchmark", capability(capabilities, "benchmark")],
|
||||
[
|
||||
"Static risk analysis",
|
||||
activeFlavour === "ecmascript"
|
||||
? "Available — advisory ECMAScript 2025 heuristics"
|
||||
: activeFlavour === "pcre2"
|
||||
? "Unavailable — ECMAScript heuristics are not applied to PCRE2"
|
||||
: "Awaiting flavour worker result",
|
||||
],
|
||||
[
|
||||
"Generated cases",
|
||||
activeFlavour === "ecmascript"
|
||||
? "Available — deterministic AST candidates, retained only after actual-engine verification"
|
||||
: activeFlavour === "pcre2"
|
||||
? "Unavailable — the partial PCRE2 provider does not expose a complete generation AST"
|
||||
: "Awaiting flavour worker result",
|
||||
],
|
||||
[
|
||||
"Pattern formatting",
|
||||
activeFlavour === "ecmascript"
|
||||
? "Available — grammar-backed literal/control escaping with mandatory exact-snapshot validation"
|
||||
: activeFlavour === "pcre2"
|
||||
? "Unavailable — no complete PCRE2 grammar-backed formatter is implemented"
|
||||
: "Awaiting flavour worker result",
|
||||
],
|
||||
[
|
||||
"Known syntax gaps",
|
||||
syntax
|
||||
? syntax.coverage.unsupportedConstructs.length > 0
|
||||
? syntax.coverage.unsupportedConstructs.join("; ")
|
||||
: "None reported by the active provider"
|
||||
: "Awaiting syntax worker result",
|
||||
],
|
||||
] as const;
|
||||
return (
|
||||
<section
|
||||
@@ -101,7 +148,20 @@ export function CapabilityPanel({
|
||||
<p className="eyebrow">Current provider and adapter metadata</p>
|
||||
<h2 id="capability-heading">Capabilities</h2>
|
||||
</div>
|
||||
<div className="panel-heading-actions">
|
||||
<span className="provenance-badge">Community build</span>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close capabilities"
|
||||
data-dialog-initial-focus
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<dl className="capability-grid">
|
||||
{rows.map(([term, value]) => (
|
||||
@@ -112,10 +172,11 @@ export function CapabilityPanel({
|
||||
))}
|
||||
</dl>
|
||||
<p className="capability-roadmap">
|
||||
Next flavour: official PCRE2 10.47 WebAssembly with actual callout
|
||||
traces. Python, Go, Rust, .NET and Java remain unavailable until their
|
||||
named runtimes pass the same worker, offset, conformance and licensing
|
||||
gates.
|
||||
PCRE2 10.47 matching and substitution run in the bundled WebAssembly
|
||||
worker. Its separate trace worker exposes bounded reported automatic
|
||||
callouts; movement classifications remain explicitly derived. Python,
|
||||
Go, Rust, .NET and Java remain unavailable until their named runtimes
|
||||
pass the same worker, offset, conformance and licensing gates.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
331
src/components/ComparisonCodePanel.css
Normal file
331
src/components/ComparisonCodePanel.css
Normal file
@@ -0,0 +1,331 @@
|
||||
.comparison-code-dialog {
|
||||
width: min(88rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.comparison-code-panel {
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.comparison-tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
padding: 0.7rem 0.8rem 0;
|
||||
}
|
||||
|
||||
.comparison-tabs button {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.72);
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.comparison-tabs button[aria-selected="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent-soft);
|
||||
}
|
||||
|
||||
.comparison-config,
|
||||
.comparison-tab-panel {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.comparison-config {
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.comparison-config-row,
|
||||
.comparison-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.comparison-config label,
|
||||
.comparison-limit-grid label,
|
||||
.comparison-text-field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.comparison-config select,
|
||||
.comparison-config input,
|
||||
.comparison-config textarea {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.65);
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
|
||||
.comparison-config textarea {
|
||||
min-height: 4.8rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.comparison-scan-control {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.comparison-variant-grid,
|
||||
.comparison-side-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.comparison-flag-selector,
|
||||
.comparison-limit-grid {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.7);
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.comparison-flag-selector legend,
|
||||
.comparison-limit-grid legend {
|
||||
padding-inline: 0.3rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.comparison-flag-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.comparison-flag-selector label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 99rem;
|
||||
padding: 0.25rem 0.45rem;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.comparison-flag-selector span {
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.comparison-limit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(6rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.comparison-subject textarea {
|
||||
min-height: 7rem;
|
||||
}
|
||||
|
||||
.comparison-actions p {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.comparison-panel-status.status-error,
|
||||
.comparison-error {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.comparison-result {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.comparison-verdict,
|
||||
.comparison-reasons,
|
||||
.comparison-differences,
|
||||
.comparison-alignments,
|
||||
.generated-code-result {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.78);
|
||||
padding: 0.8rem;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.comparison-verdict h3,
|
||||
.comparison-verdict p,
|
||||
.comparison-verdict small,
|
||||
.comparison-reasons h4,
|
||||
.comparison-differences h4,
|
||||
.comparison-alignments h4,
|
||||
.generated-code-result h3,
|
||||
.generated-code-result p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comparison-verdict {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
border-left: 4px solid var(--regex-success);
|
||||
}
|
||||
|
||||
.comparison-verdict.verdict-not-comparable {
|
||||
border-left-color: var(--regex-warning);
|
||||
}
|
||||
|
||||
.comparison-verdict.verdict-different-for-current-input {
|
||||
border-left-color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.comparison-verdict p,
|
||||
.comparison-verdict small,
|
||||
.comparison-reasons,
|
||||
.comparison-differences > p,
|
||||
.comparison-alignments > p,
|
||||
.generated-code-result li {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.77rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.comparison-reasons ul,
|
||||
.generated-code-result ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comparison-side-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.78);
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.comparison-side-card > header,
|
||||
.generated-code-result > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
padding: 0.7rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.comparison-side-card h4,
|
||||
.comparison-side-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comparison-side-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.comparison-side-card dl > div {
|
||||
min-width: 0;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.comparison-side-card dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.comparison-side-card dd {
|
||||
margin: 0.15rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.comparison-side-card details,
|
||||
.comparison-side-card > .comparison-error {
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.comparison-side-card details ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comparison-differences,
|
||||
.comparison-alignments {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.comparison-differences h4,
|
||||
.comparison-alignments h4 {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.comparison-differences td code {
|
||||
display: block;
|
||||
max-width: 22rem;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.comparison-limit-note {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.generated-code-result {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.generated-code-result > p,
|
||||
.generated-code-result > ul {
|
||||
margin-inline: 0.8rem;
|
||||
}
|
||||
|
||||
.generated-code-result pre {
|
||||
max-height: 34rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
padding: 0.8rem;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.comparison-empty-state {
|
||||
margin: 0;
|
||||
padding: 1.2rem;
|
||||
color: var(--toolbox-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.comparison-limit-grid {
|
||||
grid-template-columns: repeat(3, minmax(6rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.comparison-variant-grid,
|
||||
.comparison-side-grid,
|
||||
.comparison-side-card dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.comparison-limit-grid {
|
||||
grid-template-columns: repeat(2, minmax(6rem, 1fr));
|
||||
}
|
||||
}
|
||||
82
src/components/ComparisonCodePanel.test.tsx
Normal file
82
src/components/ComparisonCodePanel.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ComparisonCodePanel } from "./ComparisonCodePanel";
|
||||
|
||||
function renderPanel() {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ComparisonCodePanel
|
||||
activeFlavour="ecmascript"
|
||||
activeFlags={["g", "u"]}
|
||||
activeOptions={{}}
|
||||
pattern={"(?<word>\\p{Letter}+)"}
|
||||
subject="Grüße"
|
||||
replacement="$<word>!"
|
||||
scanAll
|
||||
timeoutMs={2_000}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
describe("ComparisonCodePanel", () => {
|
||||
it("exposes comparison and reviewed PCRE2 C generation through one usable path", () => {
|
||||
renderPanel();
|
||||
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Compare engines" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Shared comparison pattern" }),
|
||||
).toHaveValue("(?<word>\\p{Letter}+)");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Run comparison" }),
|
||||
).toBeEnabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "PCRE2 C code" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Generate reviewed C17" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("PCRE2 10.47 8-bit · all-matches")).toBeVisible();
|
||||
expect(screen.getByText("Exact UTF-8 byte arrays")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(/PCRE2_UTF and PCRE2_UCP are mandatory/u),
|
||||
).toBeVisible();
|
||||
const source = screen.getByText(
|
||||
/This generated program requires PCRE2 10\.47 exactly/u,
|
||||
);
|
||||
expect(source).toHaveTextContent("pcre2_set_match_limit");
|
||||
expect(source).not.toHaveTextContent("(?<word>\\p{Letter}+)");
|
||||
});
|
||||
|
||||
it("uses the explicit PCRE2 variant without rewriting it", () => {
|
||||
renderPanel();
|
||||
fireEvent.change(screen.getByLabelText("Pattern model"), {
|
||||
target: { value: "variants" },
|
||||
});
|
||||
const pcrePattern = screen.getByLabelText("PCRE2 pattern variant");
|
||||
fireEvent.change(pcrePattern, {
|
||||
target: { value: "(?<word>\\w++)" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: "PCRE2 C code" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Generate reviewed C17" }),
|
||||
);
|
||||
|
||||
const source = screen.getByText(
|
||||
/This generated program requires PCRE2 10\.47 exactly/u,
|
||||
);
|
||||
expect(source).toHaveTextContent("0x2b, 0x2b");
|
||||
expect(source).not.toHaveTextContent("(?<word>\\w++)");
|
||||
});
|
||||
|
||||
it("closes through the dialog action", () => {
|
||||
const { onClose } = renderPanel();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Close compare and generate" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
884
src/components/ComparisonCodePanel.tsx
Normal file
884
src/components/ComparisonCodePanel.tsx
Normal file
@@ -0,0 +1,884 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { writeClipboardText } from "../browser/clipboard";
|
||||
import {
|
||||
AVAILABLE_REGEX_FLAVOURS,
|
||||
defaultRegexOptions,
|
||||
} from "../regex/flavours/flavour-registry";
|
||||
import { ComparisonOrchestrator } from "../regex/comparison/ComparisonOrchestrator";
|
||||
import type {
|
||||
ComparisonFlavour,
|
||||
ComparisonPatternModel,
|
||||
RegexComparisonResult,
|
||||
} from "../regex/comparison/comparison.types";
|
||||
import {
|
||||
generatePcre2C,
|
||||
type GeneratedPcre2CProgram,
|
||||
} from "../regex/codegen/pcre2-c";
|
||||
import {
|
||||
DEFAULT_REGEX_LIMITS,
|
||||
utf8ByteLength,
|
||||
} from "../regex/execution/request-limits";
|
||||
import type {
|
||||
RegexEngineOptions,
|
||||
RegexFlavourId,
|
||||
} from "../regex/model/flavour";
|
||||
import "./ComparisonCodePanel.css";
|
||||
|
||||
const ECMASCRIPT = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
|
||||
const PCRE2 = AVAILABLE_REGEX_FLAVOURS.require("pcre2");
|
||||
const MAXIMUM_RENDERED_DIFFERENCES = 250;
|
||||
const MAXIMUM_RENDERED_ALIGNMENTS = 100;
|
||||
|
||||
type PanelTab = "compare" | "code";
|
||||
type PanelStatus =
|
||||
| { readonly kind: "idle"; readonly message: string }
|
||||
| { readonly kind: "running"; readonly message: string }
|
||||
| { readonly kind: "ready"; readonly message: string }
|
||||
| { readonly kind: "error"; readonly message: string };
|
||||
|
||||
export interface ComparisonCodePanelProps {
|
||||
readonly activeFlavour: RegexFlavourId;
|
||||
readonly activeFlags: readonly string[];
|
||||
readonly activeOptions: RegexEngineOptions;
|
||||
readonly pattern: string;
|
||||
readonly subject: string;
|
||||
readonly replacement: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly timeoutMs: number;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
function initialFlags(
|
||||
activeFlavour: RegexFlavourId,
|
||||
activeFlags: readonly string[],
|
||||
flavour: ComparisonFlavour,
|
||||
): readonly string[] {
|
||||
const definition = flavour === "ecmascript" ? ECMASCRIPT : PCRE2;
|
||||
return activeFlavour === flavour
|
||||
? definition.flags
|
||||
.map((flag) => flag.value)
|
||||
.filter((flag) => activeFlags.includes(flag))
|
||||
: definition.defaultFlags;
|
||||
}
|
||||
|
||||
function initialOptions(
|
||||
activeFlavour: RegexFlavourId,
|
||||
activeOptions: RegexEngineOptions,
|
||||
): RegexEngineOptions {
|
||||
return activeFlavour === "pcre2" ? activeOptions : defaultRegexOptions(PCRE2);
|
||||
}
|
||||
|
||||
function toggleFlag(
|
||||
current: readonly string[],
|
||||
flag: string,
|
||||
enabled: boolean,
|
||||
flavour: ComparisonFlavour,
|
||||
): readonly string[] {
|
||||
const definition = flavour === "ecmascript" ? ECMASCRIPT : PCRE2;
|
||||
const selected = new Set(current);
|
||||
if (enabled) {
|
||||
selected.add(flag);
|
||||
for (const group of definition.mutuallyExclusiveFlags ?? []) {
|
||||
if (!group.includes(flag)) continue;
|
||||
for (const incompatible of group) {
|
||||
if (incompatible !== flag) selected.delete(incompatible);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selected.delete(flag);
|
||||
}
|
||||
return definition.flags
|
||||
.map((candidate) => candidate.value)
|
||||
.filter((candidate) => selected.has(candidate));
|
||||
}
|
||||
|
||||
function preview(value: string | undefined, maximum = 240): string {
|
||||
if (value === undefined) return "—";
|
||||
return value.length <= maximum
|
||||
? value
|
||||
: `${value.slice(0, maximum)}… (${value.length.toLocaleString()} UTF-16 units)`;
|
||||
}
|
||||
|
||||
function downloadSource(program: GeneratedPcre2CProgram): void {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([program.source], { type: "text/x-c;charset=utf-8" }),
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = program.fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
||||
}
|
||||
|
||||
function engineExecution(result: RegexComparisonResult["sides"][number]) {
|
||||
return result.runtime.replacement?.execution ?? result.runtime.execution;
|
||||
}
|
||||
|
||||
function SideResult({
|
||||
side,
|
||||
}: {
|
||||
readonly side: RegexComparisonResult["sides"][number];
|
||||
}) {
|
||||
const execution = engineExecution(side);
|
||||
const diagnostics = [
|
||||
...(side.syntax.pattern?.diagnostics ?? []),
|
||||
...(side.syntax.replacement?.diagnostics ?? []),
|
||||
...(execution?.diagnostics ?? []),
|
||||
];
|
||||
return (
|
||||
<article className="comparison-side-card">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Exact request {side.requestIdentity}</p>
|
||||
<h4>
|
||||
{side.input.flavour === "ecmascript" ? "ECMAScript" : "PCRE2"}
|
||||
</h4>
|
||||
</div>
|
||||
<span className={`status status-${side.runtime.status}`}>
|
||||
{side.runtime.status}
|
||||
</span>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Pattern syntax</dt>
|
||||
<dd>
|
||||
{side.syntax.pattern
|
||||
? side.syntax.pattern.accepted
|
||||
? "Accepted"
|
||||
: "Rejected"
|
||||
: side.syntax.status}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Provider</dt>
|
||||
<dd>
|
||||
{side.syntax.pattern
|
||||
? `${side.syntax.pattern.provider.id} ${side.syntax.pattern.provider.version}`
|
||||
: "Unavailable"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Engine compile</dt>
|
||||
<dd>
|
||||
{execution
|
||||
? execution.accepted
|
||||
? "Accepted"
|
||||
: "Rejected"
|
||||
: side.runtime.status}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Engine identity</dt>
|
||||
<dd>
|
||||
{execution
|
||||
? `${execution.engine.engineName} · ${execution.engine.engineVersion}`
|
||||
: "Unavailable"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Exact flags</dt>
|
||||
<dd>
|
||||
user {side.input.flags.join("") || "none"} · effective{" "}
|
||||
{execution?.flags.effectiveFlags || "unavailable"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Options</dt>
|
||||
<dd>
|
||||
<code>{JSON.stringify(side.input.options)}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Matches</dt>
|
||||
<dd>{execution?.matches.length.toLocaleString() ?? "Unavailable"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Offsets</dt>
|
||||
<dd>
|
||||
native {execution?.engine.offsetUnit ?? "unavailable"} · comparison
|
||||
UTF-16
|
||||
</dd>
|
||||
</div>
|
||||
{side.runtime.replacement ? (
|
||||
<div>
|
||||
<dt>Replacement</dt>
|
||||
<dd>
|
||||
{side.runtime.replacement.outputBytes.toLocaleString()} bytes
|
||||
{side.runtime.replacement.truncated ? " · incomplete" : ""}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{side.syntax.pattern?.coverage.unsupportedConstructs.length ? (
|
||||
<details>
|
||||
<summary>Provider coverage gaps</summary>
|
||||
<ul>
|
||||
{side.syntax.pattern.coverage.unsupportedConstructs.map((gap) => (
|
||||
<li key={gap}>{gap}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{diagnostics.length > 0 ? (
|
||||
<details>
|
||||
<summary>{diagnostics.length.toLocaleString()} diagnostic(s)</summary>
|
||||
<ul>
|
||||
{diagnostics.slice(0, 100).map((diagnostic) => (
|
||||
<li key={`${diagnostic.source}-${diagnostic.id}`}>
|
||||
<strong>{diagnostic.severity}:</strong> {diagnostic.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{side.syntax.error || side.runtime.error ? (
|
||||
<p className="comparison-error">
|
||||
{side.syntax.error ?? side.runtime.error}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonResultView({
|
||||
result,
|
||||
}: {
|
||||
readonly result: RegexComparisonResult;
|
||||
}) {
|
||||
const visibleDifferences = result.differences.slice(
|
||||
0,
|
||||
MAXIMUM_RENDERED_DIFFERENCES,
|
||||
);
|
||||
const visibleAlignments = result.matchAlignments.slice(
|
||||
0,
|
||||
MAXIMUM_RENDERED_ALIGNMENTS,
|
||||
);
|
||||
return (
|
||||
<div className="comparison-result" aria-live="polite">
|
||||
<section className={`comparison-verdict verdict-${result.status}`}>
|
||||
<p className="eyebrow">Bounded comparison verdict</p>
|
||||
<h3>{result.status.replaceAll("-", " ")}</h3>
|
||||
<p>{result.notices[0]?.message}</p>
|
||||
<small>
|
||||
{result.elapsedMs.toFixed(1)} ms wall time ·{" "}
|
||||
{result.totalDifferences.toLocaleString()} recorded difference(s)
|
||||
</small>
|
||||
</section>
|
||||
{result.notComparable.length > 0 ? (
|
||||
<section className="comparison-reasons">
|
||||
<h4>Why this run is not comparable</h4>
|
||||
<ul>
|
||||
{result.notComparable.map((reason, index) => (
|
||||
<li key={`${reason.code}-${reason.flavour ?? "both"}-${index}`}>
|
||||
<strong>{reason.code.replaceAll("-", " ")}:</strong>{" "}
|
||||
{reason.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="comparison-side-grid">
|
||||
<SideResult side={result.sides[0]} />
|
||||
<SideResult side={result.sides[1]} />
|
||||
</div>
|
||||
<section className="comparison-differences">
|
||||
<h4>Syntax, execution and replacement differences</h4>
|
||||
{visibleDifferences.length === 0 ? (
|
||||
<p>
|
||||
No retained semantic differences for this subject. Engine metadata
|
||||
can still differ.
|
||||
</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Finding</th>
|
||||
<th>ECMAScript</th>
|
||||
<th>PCRE2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleDifferences.map((difference, index) => (
|
||||
<tr key={`${difference.kind}-${index}`}>
|
||||
<td>{difference.kind.replaceAll("-", " ")}</td>
|
||||
<td>{difference.summary}</td>
|
||||
<td>
|
||||
<code>{preview(difference.left)}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{preview(difference.right)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{visibleDifferences.length < result.totalDifferences ? (
|
||||
<p className="comparison-limit-note">
|
||||
Rendering the first {visibleDifferences.length.toLocaleString()} of{" "}
|
||||
{result.totalDifferences.toLocaleString()} differences.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
{visibleAlignments.length > 0 ? (
|
||||
<section className="comparison-alignments">
|
||||
<h4>Match alignment by normalized editor range</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alignment</th>
|
||||
<th>ECMAScript UTF-16</th>
|
||||
<th>PCRE2 UTF-16</th>
|
||||
<th>Equal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleAlignments.map((alignment, index) => (
|
||||
<tr key={`${alignment.alignment}-${index}`}>
|
||||
<td>{alignment.alignment.replaceAll("-", " ")}</td>
|
||||
<td>
|
||||
{alignment.left
|
||||
? `${alignment.left.range.startUtf16}–${alignment.left.range.endUtf16}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
{alignment.right
|
||||
? `${alignment.right.range.startUtf16}–${alignment.right.range.endUtf16}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{alignment.equal ? "yes" : "no"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{visibleAlignments.length < result.totalMatchAlignments ? (
|
||||
<p className="comparison-limit-note">
|
||||
Rendering the first {visibleAlignments.length.toLocaleString()} of{" "}
|
||||
{result.totalMatchAlignments.toLocaleString()} alignments.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComparisonCodePanel({
|
||||
activeFlavour,
|
||||
activeFlags,
|
||||
activeOptions,
|
||||
pattern: initialPattern,
|
||||
subject: initialSubject,
|
||||
replacement: initialReplacement,
|
||||
scanAll: initialScanAll,
|
||||
timeoutMs: initialTimeoutMs,
|
||||
onClose,
|
||||
}: ComparisonCodePanelProps) {
|
||||
const [tab, setTab] = useState<PanelTab>("compare");
|
||||
const [patternModel, setPatternModel] =
|
||||
useState<ComparisonPatternModel>("shared");
|
||||
const [sharedPattern, setSharedPattern] = useState(initialPattern);
|
||||
const [ecmaPattern, setEcmaPattern] = useState(initialPattern);
|
||||
const [pcrePattern, setPcrePattern] = useState(initialPattern);
|
||||
const [subject, setSubject] = useState(initialSubject);
|
||||
const [operation, setOperation] = useState<"match" | "replace">("match");
|
||||
const [ecmaReplacement, setEcmaReplacement] = useState(initialReplacement);
|
||||
const [pcreReplacement, setPcreReplacement] = useState(initialReplacement);
|
||||
const [ecmaFlags, setEcmaFlags] = useState(() =>
|
||||
initialFlags(activeFlavour, activeFlags, "ecmascript"),
|
||||
);
|
||||
const [pcreFlags, setPcreFlags] = useState(() =>
|
||||
initialFlags(activeFlavour, activeFlags, "pcre2"),
|
||||
);
|
||||
const [pcreOptions, setPcreOptions] = useState(() =>
|
||||
initialOptions(activeFlavour, activeOptions),
|
||||
);
|
||||
const [scanAll, setScanAll] = useState(initialScanAll);
|
||||
const [timeoutMs, setTimeoutMs] = useState(initialTimeoutMs);
|
||||
const [maximumMatches, setMaximumMatches] = useState(1_000);
|
||||
const [maximumCaptureRows, setMaximumCaptureRows] = useState(10_000);
|
||||
const [maximumOutputBytes, setMaximumOutputBytes] = useState(1024 * 1024);
|
||||
const [status, setStatus] = useState<PanelStatus>({
|
||||
kind: "idle",
|
||||
message: "Configure two exact engine requests.",
|
||||
});
|
||||
const [result, setResult] = useState<RegexComparisonResult>();
|
||||
const [generated, setGenerated] = useState<GeneratedPcre2CProgram>();
|
||||
const [generatedStatus, setGeneratedStatus] = useState("");
|
||||
const orchestrator = useRef<ComparisonOrchestrator | undefined>(undefined);
|
||||
const comparisonRevision = useRef(0);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
comparisonRevision.current += 1;
|
||||
orchestrator.current?.dispose();
|
||||
orchestrator.current = undefined;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const pcreOptionValues = useMemo(
|
||||
() => ({
|
||||
matchLimit: Number(pcreOptions.matchLimit),
|
||||
depthLimit: Number(pcreOptions.depthLimit),
|
||||
heapLimitKib: Number(pcreOptions.heapLimitKib),
|
||||
}),
|
||||
[pcreOptions],
|
||||
);
|
||||
|
||||
const compare = async () => {
|
||||
const revision = ++comparisonRevision.current;
|
||||
setResult(undefined);
|
||||
setStatus({
|
||||
kind: "running",
|
||||
message: "Running independently killable ECMAScript and PCRE2 workers…",
|
||||
});
|
||||
try {
|
||||
orchestrator.current ??= new ComparisonOrchestrator();
|
||||
const comparison = await orchestrator.current.compare({
|
||||
schemaVersion: 1,
|
||||
patternModel,
|
||||
operation,
|
||||
subject,
|
||||
scanAll,
|
||||
maximumMatches,
|
||||
maximumCaptureRows,
|
||||
maximumOutputBytes,
|
||||
timeoutMs,
|
||||
sides: [
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: ECMASCRIPT.defaultVersion,
|
||||
pattern: patternModel === "shared" ? sharedPattern : ecmaPattern,
|
||||
flags: ecmaFlags,
|
||||
options: {},
|
||||
...(operation === "replace"
|
||||
? { replacement: ecmaReplacement }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
flavour: "pcre2",
|
||||
flavourVersion: PCRE2.defaultVersion,
|
||||
pattern: patternModel === "shared" ? sharedPattern : pcrePattern,
|
||||
flags: pcreFlags,
|
||||
options: pcreOptionValues,
|
||||
...(operation === "replace"
|
||||
? { replacement: pcreReplacement }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
if (revision !== comparisonRevision.current) return;
|
||||
setResult(comparison);
|
||||
setStatus({
|
||||
kind: "ready",
|
||||
message:
|
||||
comparison.status === "not-comparable"
|
||||
? "Both side outcomes are retained; equivalence is not claimed."
|
||||
: "Both exact requests completed.",
|
||||
});
|
||||
} catch (error) {
|
||||
if (revision !== comparisonRevision.current) return;
|
||||
setStatus({
|
||||
kind: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
comparisonRevision.current += 1;
|
||||
orchestrator.current?.cancel();
|
||||
setStatus({
|
||||
kind: "idle",
|
||||
message: "Comparison cancelled; both active workers were terminated.",
|
||||
});
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
setGenerated(undefined);
|
||||
setGeneratedStatus("");
|
||||
try {
|
||||
const program = generatePcre2C({
|
||||
flavour: "pcre2",
|
||||
flavourVersion: PCRE2.defaultVersion,
|
||||
operation,
|
||||
pattern: patternModel === "shared" ? sharedPattern : pcrePattern,
|
||||
flags: pcreFlags,
|
||||
options: pcreOptionValues,
|
||||
subject,
|
||||
scanAll,
|
||||
...(operation === "replace" ? { replacement: pcreReplacement } : {}),
|
||||
maximumMatches,
|
||||
maximumCaptureRows,
|
||||
maximumOutputBytes,
|
||||
});
|
||||
setGenerated(program);
|
||||
setGeneratedStatus(
|
||||
"Generated locally from the exact PCRE2 snapshot shown above.",
|
||||
);
|
||||
} catch (error) {
|
||||
setGeneratedStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const setPcreOption = (name: string, value: number) => {
|
||||
setPcreOptions((current) => ({ ...current, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="panel comparison-code-panel"
|
||||
aria-labelledby="comparison-code-heading"
|
||||
>
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Evidence, not automatic translation</p>
|
||||
<h2 id="comparison-code-heading">Compare & generate</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close compare and generate"
|
||||
data-dialog-initial-focus
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="comparison-tabs" role="tablist" aria-label="Tool">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "compare"}
|
||||
onClick={() => setTab("compare")}
|
||||
>
|
||||
Compare engines
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "code"}
|
||||
onClick={() => setTab("code")}
|
||||
>
|
||||
PCRE2 C code
|
||||
</button>
|
||||
</div>
|
||||
<div className="comparison-config">
|
||||
<div className="comparison-config-row">
|
||||
<label>
|
||||
<span>Pattern model</span>
|
||||
<select
|
||||
value={patternModel}
|
||||
onChange={(event) =>
|
||||
setPatternModel(event.target.value as ComparisonPatternModel)
|
||||
}
|
||||
>
|
||||
<option value="shared">Shared pattern · unchanged</option>
|
||||
<option value="variants">Explicit per-flavour variants</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Operation</span>
|
||||
<select
|
||||
value={operation}
|
||||
onChange={(event) =>
|
||||
setOperation(event.target.value as "match" | "replace")
|
||||
}
|
||||
>
|
||||
<option value="match">Match</option>
|
||||
<option value="replace">Replace</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="check-control comparison-scan-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scanAll}
|
||||
onChange={(event) => setScanAll(event.target.checked)}
|
||||
/>
|
||||
Scan all explicitly
|
||||
</label>
|
||||
</div>
|
||||
{patternModel === "shared" ? (
|
||||
<label className="comparison-text-field">
|
||||
<span>Shared pattern sent unchanged to both engines</span>
|
||||
<textarea
|
||||
aria-label="Shared comparison pattern"
|
||||
value={sharedPattern}
|
||||
maxLength={DEFAULT_REGEX_LIMITS.patternHardLengthUtf16}
|
||||
onChange={(event) => setSharedPattern(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div className="comparison-variant-grid">
|
||||
<label className="comparison-text-field">
|
||||
<span>ECMAScript pattern variant</span>
|
||||
<textarea
|
||||
value={ecmaPattern}
|
||||
maxLength={DEFAULT_REGEX_LIMITS.patternHardLengthUtf16}
|
||||
onChange={(event) => setEcmaPattern(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="comparison-text-field">
|
||||
<span>PCRE2 pattern variant</span>
|
||||
<textarea
|
||||
value={pcrePattern}
|
||||
maxLength={DEFAULT_REGEX_LIMITS.patternHardLengthUtf16}
|
||||
onChange={(event) => setPcrePattern(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="comparison-variant-grid">
|
||||
{(
|
||||
[
|
||||
["ecmascript", ECMASCRIPT, ecmaFlags, setEcmaFlags],
|
||||
["pcre2", PCRE2, pcreFlags, setPcreFlags],
|
||||
] as const
|
||||
).map(([flavour, definition, selected, setSelected]) => (
|
||||
<fieldset className="comparison-flag-selector" key={flavour}>
|
||||
<legend>{definition.label} flags · exact</legend>
|
||||
{definition.flags.map((flag) => (
|
||||
<label key={flag.value} title={flag.label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(flag.value)}
|
||||
onChange={(event) =>
|
||||
setSelected(
|
||||
toggleFlag(
|
||||
selected,
|
||||
flag.value,
|
||||
event.target.checked,
|
||||
flavour,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<code>{flag.value}</code>
|
||||
<span>{flag.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="comparison-limit-grid">
|
||||
<legend>PCRE2 native and host bounds</legend>
|
||||
<label>
|
||||
<span>Match steps</span>
|
||||
<input
|
||||
type="number"
|
||||
value={pcreOptionValues.matchLimit}
|
||||
min={1}
|
||||
max={100_000_000}
|
||||
onChange={(event) =>
|
||||
setPcreOption("matchLimit", Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Depth</span>
|
||||
<input
|
||||
type="number"
|
||||
value={pcreOptionValues.depthLimit}
|
||||
min={1}
|
||||
max={100_000}
|
||||
onChange={(event) =>
|
||||
setPcreOption("depthLimit", Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Heap KiB</span>
|
||||
<input
|
||||
type="number"
|
||||
value={pcreOptionValues.heapLimitKib}
|
||||
min={1}
|
||||
max={131_072}
|
||||
onChange={(event) =>
|
||||
setPcreOption("heapLimitKib", Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Matches</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maximumMatches}
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumMatches}
|
||||
onChange={(event) =>
|
||||
setMaximumMatches(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Capture rows</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maximumCaptureRows}
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumCaptureRows}
|
||||
onChange={(event) =>
|
||||
setMaximumCaptureRows(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Worker ms</span>
|
||||
<input
|
||||
type="number"
|
||||
value={timeoutMs}
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs}
|
||||
onChange={(event) => setTimeoutMs(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
{operation === "replace" ? (
|
||||
<div className="comparison-variant-grid">
|
||||
<label className="comparison-text-field">
|
||||
<span>ECMAScript replacement · exact syntax</span>
|
||||
<textarea
|
||||
value={ecmaReplacement}
|
||||
maxLength={DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16}
|
||||
onChange={(event) => setEcmaReplacement(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="comparison-text-field">
|
||||
<span>PCRE2 replacement · exact syntax</span>
|
||||
<textarea
|
||||
value={pcreReplacement}
|
||||
maxLength={DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16}
|
||||
onChange={(event) => setPcreReplacement(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Output byte cap</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maximumOutputBytes}
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes}
|
||||
onChange={(event) =>
|
||||
setMaximumOutputBytes(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="comparison-text-field comparison-subject">
|
||||
<span>
|
||||
Shared subject · {utf8ByteLength(subject).toLocaleString()} UTF-8
|
||||
bytes
|
||||
</span>
|
||||
<textarea
|
||||
aria-label="Comparison subject"
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{tab === "compare" ? (
|
||||
<div role="tabpanel" className="comparison-tab-panel">
|
||||
<div className="comparison-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={status.kind === "running"}
|
||||
onClick={() => void compare()}
|
||||
>
|
||||
{status.kind === "running" ? "Comparing…" : "Run comparison"}
|
||||
</button>
|
||||
{status.kind === "running" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={cancel}
|
||||
>
|
||||
Cancel both workers
|
||||
</button>
|
||||
) : null}
|
||||
<p className={`comparison-panel-status status-${status.kind}`}>
|
||||
{status.message}
|
||||
</p>
|
||||
</div>
|
||||
{result ? <ComparisonResultView result={result} /> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div role="tabpanel" className="comparison-tab-panel codegen-panel">
|
||||
<div className="comparison-actions">
|
||||
<button type="button" className="primary-button" onClick={generate}>
|
||||
Generate reviewed C17
|
||||
</button>
|
||||
{generated ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
void writeClipboardText(generated.source)
|
||||
.then(() =>
|
||||
setGeneratedStatus("Complete generated source copied."),
|
||||
)
|
||||
.catch((error: unknown) =>
|
||||
setGeneratedStatus(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy complete source
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => downloadSource(generated)}
|
||||
>
|
||||
Download .c
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<p role="status">{generatedStatus}</p>
|
||||
</div>
|
||||
{generated ? (
|
||||
<section className="generated-code-result">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
{generated.engineIdentity} · {generated.represents}
|
||||
</p>
|
||||
<h3>{generated.fileName}</h3>
|
||||
</div>
|
||||
<span className="provenance-badge">
|
||||
Exact UTF-8 byte arrays
|
||||
</span>
|
||||
</header>
|
||||
<p>
|
||||
Compile: <code>{generated.compileCommand}</code>
|
||||
</p>
|
||||
<ul>
|
||||
{generated.caveats.map((caveat) => (
|
||||
<li key={caveat}>{caveat}</li>
|
||||
))}
|
||||
</ul>
|
||||
<pre tabIndex={0}>
|
||||
<code>{generated.source}</code>
|
||||
</pre>
|
||||
</section>
|
||||
) : (
|
||||
<p className="comparison-empty-state">
|
||||
Generation consumes the exact PCRE2 pattern, flags, native limits,
|
||||
subject and optional replacement above. It never powers the
|
||||
browser runtime.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
253
src/components/CorpusPanel.test.tsx
Normal file
253
src/components/CorpusPanel.test.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RegexExecutionRequest } from "../regex/model/match";
|
||||
|
||||
const engineHarness = vi.hoisted(() => ({
|
||||
cancelCalls: 0,
|
||||
disposeCalls: 0,
|
||||
executeRequests: [] as RegexExecutionRequest[],
|
||||
}));
|
||||
|
||||
vi.mock("../regex/execution/EngineSupervisor", () => ({
|
||||
EngineSupervisor: class {
|
||||
execute(request: RegexExecutionRequest) {
|
||||
engineHarness.executeRequests.push(request);
|
||||
return Promise.resolve({
|
||||
accepted: true,
|
||||
engine: {
|
||||
flavour: "ecmascript",
|
||||
adapterVersion: "test",
|
||||
engineName: "Test engine",
|
||||
engineVersion: "1",
|
||||
runtimeVersion: "1",
|
||||
offsetUnit: "utf16",
|
||||
capabilities: {
|
||||
compilation: true,
|
||||
matching: true,
|
||||
replacement: true,
|
||||
namedCaptures: true,
|
||||
captureHistory: false,
|
||||
actualTrace: false,
|
||||
benchmark: false,
|
||||
},
|
||||
},
|
||||
flags: {
|
||||
userFlags: "",
|
||||
effectiveFlags: "g",
|
||||
internallyAddedIndicesFlag: true,
|
||||
internallyAddedGlobalFlag: true,
|
||||
},
|
||||
matches: [
|
||||
{
|
||||
matchNumber: 1,
|
||||
value: "42",
|
||||
valueStatus: "complete",
|
||||
range: { startUtf16: 0, endUtf16: 2 },
|
||||
nativeRange: { start: 0, end: 2, unit: "utf16" },
|
||||
captures: [],
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
elapsedMs: 2,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
replace(): Promise<never> {
|
||||
return Promise.reject(new Error("Unexpected replacement call"));
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
engineHarness.cancelCalls += 1;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
engineHarness.disposeCalls += 1;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { CorpusPanel } from "./CorpusPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
engineHarness.cancelCalls = 0;
|
||||
engineHarness.disposeCalls = 0;
|
||||
engineHarness.executeRequests.length = 0;
|
||||
});
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<Parameters<typeof CorpusPanel>[0]> = {},
|
||||
) {
|
||||
return render(
|
||||
<CorpusPanel
|
||||
active
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
options={{}}
|
||||
pattern="\\d+"
|
||||
flags={[]}
|
||||
captureMetadata={[]}
|
||||
patternAccepted
|
||||
replacement="[$&]"
|
||||
replacementAccepted
|
||||
onReplacementChange={() => {}}
|
||||
timeoutMs={2_000}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CorpusPanel", () => {
|
||||
it("adds pasted text and produces an exhaustive per-document summary", async () => {
|
||||
renderPanel();
|
||||
await userEvent.type(
|
||||
screen.getByRole("textbox", { name: "Pasted corpus text" }),
|
||||
"42",
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Add pasted text" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("list", { name: "Corpus documents" }),
|
||||
).toHaveTextContent("pasted-text.txt");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Scan corpus" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("table", { name: "Corpus document results" }),
|
||||
).toHaveTextContent("complete");
|
||||
expect(engineHarness.executeRequests).toHaveLength(1);
|
||||
expect(engineHarness.executeRequests[0]).toMatchObject({
|
||||
subject: "42",
|
||||
scanAll: true,
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Download summary JSON" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("blocks invalid runs and clears results when configuration changes", async () => {
|
||||
const rendered = renderPanel({ patternAccepted: false });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add pasted text" }));
|
||||
expect(screen.getByRole("button", { name: "Scan corpus" })).toBeDisabled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"syntax provider accepts",
|
||||
);
|
||||
|
||||
rendered.rerender(
|
||||
<CorpusPanel
|
||||
active
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
options={{}}
|
||||
pattern="changed"
|
||||
flags={[]}
|
||||
captureMetadata={[]}
|
||||
patternAccepted
|
||||
replacement="[$&]"
|
||||
replacementAccepted
|
||||
onReplacementChange={() => {}}
|
||||
timeoutMs={2_000}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Scan corpus" })).toBeEnabled();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"previous corpus results were cleared",
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels an active batch when the workspace is left", async () => {
|
||||
let resolveExecution: ((value: never) => void) | undefined;
|
||||
const pending = new Promise<never>((resolve) => {
|
||||
resolveExecution = resolve;
|
||||
});
|
||||
const execute = vi
|
||||
.spyOn(
|
||||
(await import("../regex/execution/EngineSupervisor")).EngineSupervisor
|
||||
.prototype,
|
||||
"execute",
|
||||
)
|
||||
.mockReturnValue(pending);
|
||||
const rendered = renderPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add pasted text" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan corpus" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Cancel corpus run" }),
|
||||
).toBeEnabled();
|
||||
|
||||
rendered.rerender(
|
||||
<CorpusPanel
|
||||
active={false}
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
options={{}}
|
||||
pattern="\\d+"
|
||||
flags={[]}
|
||||
captureMetadata={[]}
|
||||
patternAccepted
|
||||
replacement="[$&]"
|
||||
replacementAccepted
|
||||
onReplacementChange={() => {}}
|
||||
timeoutMs={2_000}
|
||||
/>,
|
||||
);
|
||||
expect(engineHarness.cancelCalls).toBeGreaterThan(0);
|
||||
execute.mockRestore();
|
||||
await act(async () => {
|
||||
resolveExecution?.(undefined as never);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish stale batch results after configuration changes", async () => {
|
||||
let resolveExecution: ((value: never) => void) | undefined;
|
||||
const pending = new Promise<never>((resolve) => {
|
||||
resolveExecution = resolve;
|
||||
});
|
||||
const execute = vi
|
||||
.spyOn(
|
||||
(await import("../regex/execution/EngineSupervisor")).EngineSupervisor
|
||||
.prototype,
|
||||
"execute",
|
||||
)
|
||||
.mockReturnValue(pending);
|
||||
const rendered = renderPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add pasted text" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan corpus" }));
|
||||
|
||||
rendered.rerender(
|
||||
<CorpusPanel
|
||||
active
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
options={{}}
|
||||
pattern="changed"
|
||||
flags={[]}
|
||||
captureMetadata={[]}
|
||||
patternAccepted
|
||||
replacement="[$&]"
|
||||
replacementAccepted
|
||||
onReplacementChange={() => {}}
|
||||
timeoutMs={2_000}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
resolveExecution?.(undefined as never);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("table", { name: "Corpus document results" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"previous corpus results were cleared",
|
||||
);
|
||||
execute.mockRestore();
|
||||
});
|
||||
});
|
||||
1024
src/components/CorpusPanel.tsx
Normal file
1024
src/components/CorpusPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
314
src/components/GenerationPanel.css
Normal file
314
src/components/GenerationPanel.css
Normal file
@@ -0,0 +1,314 @@
|
||||
.generation-dialog {
|
||||
width: min(90rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.generation-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.generation-disclaimer,
|
||||
.generation-unavailable,
|
||||
.generation-status,
|
||||
.generation-summary,
|
||||
.generation-coverage,
|
||||
.generation-cases,
|
||||
.generation-discarded {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.82);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-surface) 94%,
|
||||
var(--toolbox-accent) 6%
|
||||
);
|
||||
}
|
||||
|
||||
.generation-disclaimer {
|
||||
margin: 0;
|
||||
padding: 0.8rem 0.95rem;
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.generation-unavailable {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.generation-unavailable span {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.generation-settings {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.82);
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.generation-settings-primary,
|
||||
.generation-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.generation-settings label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.generation-settings input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.generation-settings details {
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
.generation-settings details summary,
|
||||
.generation-discarded summary,
|
||||
.generation-coverage details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.generation-settings details[open] summary {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.generation-run-actions,
|
||||
.generation-case-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.generation-run-actions small {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.generation-status {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.generation-status.is-error {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 55%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
}
|
||||
|
||||
.generation-status progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.generation-status span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.generation-results {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.generation-summary,
|
||||
.generation-coverage,
|
||||
.generation-cases,
|
||||
.generation-discarded {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.generation-summary dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.generation-summary dl > div {
|
||||
min-width: 0;
|
||||
padding: 0.65rem;
|
||||
border-radius: calc(var(--toolbox-radius) * 0.68);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.generation-summary dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.generation-summary dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.generation-warnings {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.generation-coverage > header,
|
||||
.generation-cases > header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.generation-coverage h3,
|
||||
.generation-cases h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.generation-coverage > ul,
|
||||
.unsupported-generation-list,
|
||||
.generated-case-list,
|
||||
.generation-discarded ul {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.generation-coverage > ul > li {
|
||||
display: grid;
|
||||
grid-template-columns: 7.5rem minmax(9rem, 0.45fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: calc(var(--toolbox-radius) * 0.68);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.generation-coverage .status {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.generation-coverage details {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.unsupported-generation-list {
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.unsupported-generation-list li,
|
||||
.generation-discarded li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 0.35fr) minmax(8rem, 0.25fr) minmax(
|
||||
0,
|
||||
1fr
|
||||
);
|
||||
gap: 0.6rem;
|
||||
align-items: start;
|
||||
padding: 0.55rem;
|
||||
border-radius: calc(var(--toolbox-radius) * 0.68);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.unsupported-generation-list code,
|
||||
.generation-discarded code {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.generated-case-list li {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.68);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.generated-case-list label {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.generated-case-list label span {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.generated-case-list small {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.generated-case-list > li > code {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.generation-feature-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.generation-feature-chips span {
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 999px;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.generation-discarded > p {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.generation-discarded[open] summary {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.generation-settings-primary,
|
||||
.generation-settings-grid,
|
||||
.generation-summary dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.generation-coverage > ul > li,
|
||||
.unsupported-generation-list li,
|
||||
.generation-discarded li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.generation-settings-primary,
|
||||
.generation-settings-grid,
|
||||
.generation-summary dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.generation-coverage > header,
|
||||
.generation-cases > header {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
326
src/components/GenerationPanel.test.tsx
Normal file
326
src/components/GenerationPanel.test.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CaseGenerationClient } from "../regex/generation/CaseGenerationOrchestrator";
|
||||
import type {
|
||||
CaseGenerationResult,
|
||||
VerifiedGeneratedCase,
|
||||
} from "../regex/generation/generation.types";
|
||||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||||
import { EcmaScriptSyntaxProvider } from "../regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
|
||||
import { GenerationPanel } from "./GenerationPanel";
|
||||
|
||||
const provider = new EcmaScriptSyntaxProvider();
|
||||
|
||||
function generatedCase(
|
||||
id: string,
|
||||
expectation: "should-match" | "should-not-match",
|
||||
): VerifiedGeneratedCase {
|
||||
const subject = expectation === "should-match" ? "cat" : "dog";
|
||||
return {
|
||||
id,
|
||||
name: `Generated ${id}`,
|
||||
subject,
|
||||
subjectBytes: subject.length,
|
||||
expectation,
|
||||
features:
|
||||
expectation === "should-match" ? ["shortest"] : ["likely-near-miss"],
|
||||
notes: [],
|
||||
elapsedMs: 0.5,
|
||||
matched: expectation === "should-match",
|
||||
matchCount: expectation === "should-match" ? 1 : 0,
|
||||
provenance: {
|
||||
kind: "generated",
|
||||
generatorId: "regex-tools-ast-cases",
|
||||
generatorVersion: "1",
|
||||
seed: "case-seed-1",
|
||||
candidateId: id,
|
||||
intendedOutcome: expectation === "should-match" ? "match" : "no-match",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixtureResult(): CaseGenerationResult {
|
||||
return {
|
||||
status: "partial",
|
||||
seed: "case-seed-1",
|
||||
seedHash: "1234abcd",
|
||||
flavour: "ecmascript",
|
||||
cases: [
|
||||
generatedCase("positive", "should-match"),
|
||||
generatedCase("negative", "should-not-match"),
|
||||
],
|
||||
discarded: [
|
||||
{
|
||||
candidateId: "discarded",
|
||||
intendedOutcome: "no-match",
|
||||
reason: "unexpected-match",
|
||||
detail: "The actual engine matched it.",
|
||||
subjectBytes: 3,
|
||||
subjectPreview: "cat",
|
||||
},
|
||||
],
|
||||
coverage: [
|
||||
{
|
||||
feature: "shortest",
|
||||
status: "covered",
|
||||
detail: "Shortest path requested.",
|
||||
relevantNodes: 1,
|
||||
},
|
||||
{
|
||||
feature: "alternative-coverage",
|
||||
status: "not-applicable",
|
||||
detail: "No alternatives.",
|
||||
relevantNodes: 0,
|
||||
},
|
||||
],
|
||||
unsupportedConstructs: [
|
||||
{
|
||||
id: "unsupported-lookahead",
|
||||
kind: "lookahead",
|
||||
range: { startUtf16: 0, endUtf16: 3 },
|
||||
rawPreview: "(?=cat)",
|
||||
reason: "Lookaround is verified but not synthesized.",
|
||||
},
|
||||
],
|
||||
engine: {
|
||||
flavour: "ecmascript",
|
||||
adapterVersion: "fixture",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Fixture Browser 1",
|
||||
runtimeVersion: "Fixture Browser 1",
|
||||
offsetUnit: "utf16",
|
||||
capabilities: {
|
||||
compilation: true,
|
||||
matching: true,
|
||||
replacement: true,
|
||||
namedCaptures: true,
|
||||
captureHistory: false,
|
||||
actualTrace: false,
|
||||
benchmark: true,
|
||||
},
|
||||
},
|
||||
candidateAttempts: 5,
|
||||
engineExecutions: 3,
|
||||
generatedCandidateBytes: 9,
|
||||
verifiedSubjectBytes: 6,
|
||||
wallTimeMs: 4.5,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function client(
|
||||
overrides: Partial<CaseGenerationClient> = {},
|
||||
): CaseGenerationClient {
|
||||
return {
|
||||
run: vi.fn().mockImplementation(async (_input, onProgress) => {
|
||||
onProgress?.({
|
||||
phase: "verifying",
|
||||
completed: 1,
|
||||
total: 3,
|
||||
retained: 1,
|
||||
message: "Verifying fixture candidate.",
|
||||
});
|
||||
return fixtureResult();
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPanel(
|
||||
overrides: Partial<Parameters<typeof GenerationPanel>[0]> = {},
|
||||
) {
|
||||
const pattern = overrides.pattern ?? "cat";
|
||||
const syntax =
|
||||
overrides.syntax ??
|
||||
(await provider.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "2025",
|
||||
pattern,
|
||||
flags: [],
|
||||
options: {},
|
||||
}));
|
||||
const currentClient = client();
|
||||
const rendered = render(
|
||||
<GenerationPanel
|
||||
active
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
pattern={pattern}
|
||||
flags={[]}
|
||||
options={{}}
|
||||
scanAll={false}
|
||||
syntax={syntax}
|
||||
createOrchestrator={() => currentClient}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { ...rendered, currentClient, syntax };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("GenerationPanel", () => {
|
||||
it("runs deterministic generation, renders honest coverage and adds verified cases to tests", async () => {
|
||||
const onAddToTests = vi.fn();
|
||||
const onSelectPatternRange = vi.fn();
|
||||
const { currentClient } = await renderPanel({
|
||||
onAddToTests,
|
||||
onSelectPatternRange,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Generate & verify" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Native ECMAScript RegExp · Fixture Browser 1/u),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("case-seed-1")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Generation summary" }),
|
||||
).toHaveTextContent("1234abcd");
|
||||
expect(screen.getByText("Shortest path requested.")).toBeVisible();
|
||||
expect(screen.getAllByText("actual engine verified")).toHaveLength(2);
|
||||
await userEvent.click(
|
||||
screen.getByText(/1 unsupported or partially synthesized construct/u),
|
||||
);
|
||||
expect(currentClient.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
seed: "case-seed-1",
|
||||
flavour: "ecmascript",
|
||||
pattern: "cat",
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: /lookahead · 0–3/u,
|
||||
}),
|
||||
);
|
||||
expect(onSelectPatternRange).toHaveBeenCalledWith({
|
||||
startUtf16: 0,
|
||||
endUtf16: 3,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByText(/Advanced: 1 rejected candidate diagnostic/u),
|
||||
);
|
||||
expect(screen.getByText(/unexpected match/u)).toBeVisible();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Add selected to tests" }),
|
||||
);
|
||||
expect(onAddToTests).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
expectation: "should-match",
|
||||
provenance: expect.objectContaining({ seed: "case-seed-1" }),
|
||||
}),
|
||||
expect.objectContaining({ expectation: "should-not-match" }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Added 2 generated cases to the unit-test suite/u),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows strict setting errors without starting worker execution", async () => {
|
||||
const { currentClient } = await renderPanel();
|
||||
const maximumCases = screen.getByRole("spinbutton", {
|
||||
name: "Retain at most",
|
||||
});
|
||||
await userEvent.clear(maximumCases);
|
||||
await userEvent.type(maximumCases, "500");
|
||||
await userEvent.click(screen.getByText("Advanced generation bounds"));
|
||||
const attempts = screen.getByRole("spinbutton", {
|
||||
name: "Candidate attempts",
|
||||
});
|
||||
await userEvent.clear(attempts);
|
||||
await userEvent.type(attempts, "100");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Generate & verify" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Maximum candidate attempts must be/u),
|
||||
).toBeVisible();
|
||||
expect(currentClient.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not expose the ECMAScript generator for PCRE2", async () => {
|
||||
const { currentClient } = await renderPanel({ flavour: "pcre2" });
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/current PCRE2 syntax provider is intentionally partial/u,
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Generate & verify" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(currentClient.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears verified cases immediately when the pattern changes", async () => {
|
||||
const rendered = await renderPanel();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Generate & verify" }),
|
||||
);
|
||||
expect(await screen.findByText("Generated positive")).toBeVisible();
|
||||
|
||||
rendered.rerender(
|
||||
<GenerationPanel
|
||||
active
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
pattern="changed"
|
||||
flags={[]}
|
||||
options={{}}
|
||||
scanAll={false}
|
||||
syntax={rendered.syntax}
|
||||
createOrchestrator={() => rendered.currentClient}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Generated positive")).not.toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/previous generated cases were cleared/u),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("terminates synthesis and engine workers on cancellation", async () => {
|
||||
let rejectRun: ((error: Error) => void) | undefined;
|
||||
const pending = new Promise<CaseGenerationResult>((_resolve, reject) => {
|
||||
rejectRun = reject;
|
||||
});
|
||||
const currentClient = client({
|
||||
run: vi.fn().mockReturnValue(pending),
|
||||
cancel: vi.fn().mockImplementation(() => {
|
||||
rejectRun?.(
|
||||
new WorkerRequestError("cancelled", "fixture cancellation"),
|
||||
);
|
||||
}),
|
||||
});
|
||||
await renderPanel({ createOrchestrator: () => currentClient });
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Generate & verify" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "Cancel generation" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(currentClient.cancel).toHaveBeenCalled());
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Generation cancelled; its workers were terminated/u,
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(currentClient.dispose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
829
src/components/GenerationPanel.tsx
Normal file
829
src/components/GenerationPanel.tsx
Normal file
@@ -0,0 +1,829 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CaseGenerationOrchestrator,
|
||||
type CaseGenerationClient,
|
||||
} from "../regex/generation/CaseGenerationOrchestrator";
|
||||
import {
|
||||
DEFAULT_GENERATION_SETTINGS,
|
||||
GENERATION_LIMITS,
|
||||
} from "../regex/generation/generation-limits";
|
||||
import type {
|
||||
CaseGenerationProgress,
|
||||
CaseGenerationResult,
|
||||
CandidateGenerationSettings,
|
||||
VerifiedGeneratedCase,
|
||||
} from "../regex/generation/generation.types";
|
||||
import {
|
||||
GENERATED_CASE_GENERATOR_ID,
|
||||
GENERATED_CASE_GENERATOR_VERSION,
|
||||
} from "../regex/generation/generation.types";
|
||||
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
|
||||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||||
import type {
|
||||
RegexEngineOptions,
|
||||
RegexFlavourId,
|
||||
} from "../regex/model/flavour";
|
||||
import type { RegexSyntaxResult, SourceRange } from "../regex/model/syntax";
|
||||
import "./GenerationPanel.css";
|
||||
|
||||
const MAXIMUM_SUBJECT_PREVIEW_UTF16 = 240;
|
||||
|
||||
function defaultOrchestrator(): CaseGenerationClient {
|
||||
return new CaseGenerationOrchestrator();
|
||||
}
|
||||
|
||||
function integer(
|
||||
source: string,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const value = Number(source);
|
||||
if (
|
||||
source.trim() === "" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < minimum ||
|
||||
value > maximum
|
||||
) {
|
||||
throw new RangeError(
|
||||
`${label} must be a whole number from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function subjectPreview(subject: string): string {
|
||||
if (subject.length <= MAXIMUM_SUBJECT_PREVIEW_UTF16) {
|
||||
return JSON.stringify(subject);
|
||||
}
|
||||
return `${JSON.stringify(subject.slice(0, MAXIMUM_SUBJECT_PREVIEW_UTF16))}… (${subject.length.toLocaleString()} UTF-16 units)`;
|
||||
}
|
||||
|
||||
function downloadCases(
|
||||
result: CaseGenerationResult,
|
||||
configuration: {
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly flavourVersion?: string;
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly scanAll: boolean;
|
||||
},
|
||||
): void {
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: "regex-tools-generated-cases",
|
||||
generator: {
|
||||
id: GENERATED_CASE_GENERATOR_ID,
|
||||
version: GENERATED_CASE_GENERATOR_VERSION,
|
||||
seed: result.seed,
|
||||
seedHash: result.seedHash,
|
||||
},
|
||||
configuration,
|
||||
engine: result.engine,
|
||||
cases: result.cases.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
subject: candidate.subject,
|
||||
expectation: { kind: candidate.expectation },
|
||||
features: candidate.features,
|
||||
provenance: candidate.provenance,
|
||||
})),
|
||||
};
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([`${JSON.stringify(payload, null, 2)}\n`], {
|
||||
type: "application/json",
|
||||
}),
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `regex-generated-${result.seedHash}.json`;
|
||||
anchor.click();
|
||||
globalThis.setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
||||
}
|
||||
|
||||
export interface GenerationPanelProps {
|
||||
readonly active: boolean;
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly flavourVersion?: string;
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly scanAll: boolean;
|
||||
readonly syntax?: RegexSyntaxResult;
|
||||
readonly onClose?: () => void;
|
||||
readonly onAddToTests?: (cases: readonly VerifiedGeneratedCase[]) => void;
|
||||
readonly onSelectPatternRange?: (range: SourceRange) => void;
|
||||
readonly createOrchestrator?: () => CaseGenerationClient;
|
||||
}
|
||||
|
||||
export function GenerationPanel({
|
||||
active,
|
||||
flavour,
|
||||
flavourVersion,
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
scanAll,
|
||||
syntax,
|
||||
onClose,
|
||||
onAddToTests,
|
||||
onSelectPatternRange,
|
||||
createOrchestrator = defaultOrchestrator,
|
||||
}: GenerationPanelProps) {
|
||||
const [seed, setSeed] = useState("case-seed-1");
|
||||
const [maximumCases, setMaximumCases] = useState(
|
||||
String(DEFAULT_GENERATION_SETTINGS.maximumCases),
|
||||
);
|
||||
const [maximumCandidateAttempts, setMaximumCandidateAttempts] = useState(
|
||||
String(DEFAULT_GENERATION_SETTINGS.maximumCandidateAttempts),
|
||||
);
|
||||
const [randomVariantCount, setRandomVariantCount] = useState(
|
||||
String(DEFAULT_GENERATION_SETTINGS.randomVariantCount),
|
||||
);
|
||||
const [maximumQuantifierRepetitions, setMaximumQuantifierRepetitions] =
|
||||
useState(String(DEFAULT_GENERATION_SETTINGS.maximumQuantifierRepetitions));
|
||||
const [maximumSubjectBytes, setMaximumSubjectBytes] = useState(
|
||||
String(DEFAULT_GENERATION_SETTINGS.maximumSubjectBytes),
|
||||
);
|
||||
const [maximumTotalSubjectBytes, setMaximumTotalSubjectBytes] = useState(
|
||||
String(DEFAULT_GENERATION_SETTINGS.maximumTotalSubjectBytes),
|
||||
);
|
||||
const [perCaseTimeoutMs, setPerCaseTimeoutMs] = useState(
|
||||
String(DEFAULT_GENERATION_SETTINGS.perCaseTimeoutMs),
|
||||
);
|
||||
const [maximumWallTimeMs, setMaximumWallTimeMs] = useState(
|
||||
String(DEFAULT_GENERATION_SETTINGS.maximumWallTimeMs),
|
||||
);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<CaseGenerationProgress>();
|
||||
const [message, setMessage] = useState(
|
||||
"No cases have been generated for this configuration.",
|
||||
);
|
||||
const [error, setError] = useState<string>();
|
||||
const [resultRecord, setResultRecord] = useState<{
|
||||
readonly configurationKey: symbol;
|
||||
readonly result: CaseGenerationResult;
|
||||
}>();
|
||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const orchestrator = useRef<CaseGenerationClient | undefined>(undefined);
|
||||
const runRevision = useRef(0);
|
||||
|
||||
const syntaxCurrent =
|
||||
syntax?.accepted === true &&
|
||||
syntax.root.raw === pattern &&
|
||||
syntax.root.support.flavour === flavour;
|
||||
const generationAvailable = flavour === "ecmascript" && syntaxCurrent;
|
||||
const configurationKey = useMemo(
|
||||
() =>
|
||||
Symbol(
|
||||
`generation-${flavour}-${flavourVersion ?? ""}-${flags.join("")}-${JSON.stringify(options)}-${pattern.length}-${String(scanAll)}`,
|
||||
),
|
||||
[flags, flavour, flavourVersion, options, pattern, scanAll],
|
||||
);
|
||||
const previousConfiguration = useRef(configurationKey);
|
||||
const result =
|
||||
resultRecord?.configurationKey === configurationKey
|
||||
? resultRecord.result
|
||||
: undefined;
|
||||
|
||||
const disposeActive = useCallback(() => {
|
||||
orchestrator.current?.cancel();
|
||||
orchestrator.current?.dispose();
|
||||
orchestrator.current = undefined;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
runRevision.current += 1;
|
||||
disposeActive();
|
||||
queueMicrotask(() => setRunning(false));
|
||||
}, [active, disposeActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousConfiguration.current === configurationKey) return;
|
||||
previousConfiguration.current = configurationKey;
|
||||
const revision = ++runRevision.current;
|
||||
disposeActive();
|
||||
queueMicrotask(() => {
|
||||
if (runRevision.current !== revision) return;
|
||||
setRunning(false);
|
||||
setProgress(undefined);
|
||||
setResultRecord(undefined);
|
||||
setSelectedIds(new Set());
|
||||
setError(undefined);
|
||||
setMessage(
|
||||
"Pattern or engine configuration changed; previous generated cases were cleared.",
|
||||
);
|
||||
});
|
||||
}, [configurationKey, disposeActive]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
runRevision.current += 1;
|
||||
disposeActive();
|
||||
},
|
||||
[disposeActive],
|
||||
);
|
||||
|
||||
const settings = (): CandidateGenerationSettings => {
|
||||
const retained = integer(
|
||||
maximumCases,
|
||||
"Maximum retained cases",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumGeneratedCases,
|
||||
);
|
||||
return {
|
||||
maximumCases: retained,
|
||||
maximumCandidateAttempts: integer(
|
||||
maximumCandidateAttempts,
|
||||
"Maximum candidate attempts",
|
||||
retained,
|
||||
GENERATION_LIMITS.maximumCandidateAttempts,
|
||||
),
|
||||
maximumTotalSubjectBytes: integer(
|
||||
maximumTotalSubjectBytes,
|
||||
"Maximum aggregate subject bytes",
|
||||
1,
|
||||
GENERATION_LIMITS.maximumTotalSubjectBytes,
|
||||
),
|
||||
maximumSubjectBytes: integer(
|
||||
maximumSubjectBytes,
|
||||
"Maximum bytes per subject",
|
||||
1,
|
||||
GENERATION_LIMITS.maximumSubjectBytes,
|
||||
),
|
||||
maximumAstNodes: DEFAULT_GENERATION_SETTINGS.maximumAstNodes,
|
||||
maximumAstDepth: DEFAULT_GENERATION_SETTINGS.maximumAstDepth,
|
||||
maximumQuantifierRepetitions: integer(
|
||||
maximumQuantifierRepetitions,
|
||||
"Maximum synthesized repetitions",
|
||||
1,
|
||||
GENERATION_LIMITS.maximumQuantifierRepetitions,
|
||||
),
|
||||
randomVariantCount: integer(
|
||||
randomVariantCount,
|
||||
"Random variants",
|
||||
0,
|
||||
DEFAULT_REGEX_LIMITS.maximumGeneratedCases,
|
||||
),
|
||||
perCaseTimeoutMs: integer(
|
||||
perCaseTimeoutMs,
|
||||
"Per-case timeout",
|
||||
GENERATION_LIMITS.minimumPerCaseTimeoutMs,
|
||||
GENERATION_LIMITS.maximumPerCaseTimeoutMs,
|
||||
),
|
||||
maximumWallTimeMs: integer(
|
||||
maximumWallTimeMs,
|
||||
"Aggregate wall time",
|
||||
1,
|
||||
GENERATION_LIMITS.maximumWallTimeMs,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
if (!generationAvailable || !syntax) return;
|
||||
const revision = ++runRevision.current;
|
||||
disposeActive();
|
||||
const client = createOrchestrator();
|
||||
orchestrator.current = client;
|
||||
setRunning(true);
|
||||
setProgress(undefined);
|
||||
setError(undefined);
|
||||
setResultRecord(undefined);
|
||||
setSelectedIds(new Set());
|
||||
setMessage(
|
||||
"Starting deterministic synthesis and actual-engine verification…",
|
||||
);
|
||||
try {
|
||||
const next = await client.run(
|
||||
{
|
||||
flavour,
|
||||
...(flavourVersion === undefined ? {} : { flavourVersion }),
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
scanAll,
|
||||
root: syntax.root,
|
||||
captureMetadata: syntax.captures,
|
||||
syntaxAccepted: syntax.accepted,
|
||||
seed,
|
||||
settings: settings(),
|
||||
},
|
||||
(value) => {
|
||||
if (runRevision.current !== revision) return;
|
||||
setProgress(value);
|
||||
setMessage(value.message);
|
||||
},
|
||||
);
|
||||
if (runRevision.current !== revision) return;
|
||||
setResultRecord({ configurationKey, result: next });
|
||||
setSelectedIds(new Set(next.cases.map((candidate) => candidate.id)));
|
||||
setMessage(
|
||||
next.stoppedReason ??
|
||||
`Generation ${next.status}: ${next.cases.length.toLocaleString()} actual-engine-verified cases retained.`,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (runRevision.current !== revision) return;
|
||||
if (cause instanceof WorkerRequestError && cause.kind === "cancelled") {
|
||||
setMessage("Generation cancelled; its workers were terminated.");
|
||||
} else {
|
||||
const detail =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Generated cases could not be produced.";
|
||||
setError(detail);
|
||||
setMessage("Generation settings or worker execution require review.");
|
||||
}
|
||||
} finally {
|
||||
client.dispose();
|
||||
if (orchestrator.current === client) orchestrator.current = undefined;
|
||||
if (runRevision.current === revision) {
|
||||
setRunning(false);
|
||||
setProgress(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
orchestrator.current?.cancel();
|
||||
setMessage(
|
||||
"Cancelling synthesis and verification; active workers are being terminated…",
|
||||
);
|
||||
};
|
||||
|
||||
const selectedCases =
|
||||
result?.cases.filter((candidate) => selectedIds.has(candidate.id)) ?? [];
|
||||
const progressMaximum = Math.max(1, progress?.total ?? 1);
|
||||
const progressValue = Math.min(progressMaximum, progress?.completed ?? 0);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="panel generation-panel"
|
||||
aria-labelledby="generation-heading"
|
||||
>
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
Normalized AST candidates · actual-engine truth
|
||||
</p>
|
||||
<h2 id="generation-heading">Generated test cases</h2>
|
||||
</div>
|
||||
<div className="panel-heading-actions">
|
||||
<span className="provenance-badge">
|
||||
{generationAvailable
|
||||
? "ECMAScript generator v1"
|
||||
: "Coverage unavailable"}
|
||||
</span>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close generated test cases"
|
||||
data-dialog-initial-focus
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="generation-disclaimer">
|
||||
Generation samples supported structure; it is not proof of complete
|
||||
coverage. A positive or negative label appears only after the selected
|
||||
actual engine confirms that exact subject. Rejected candidates remain
|
||||
diagnostics, never tests.
|
||||
</p>
|
||||
|
||||
{!generationAvailable ? (
|
||||
<div className="generation-unavailable" role="status">
|
||||
<strong>
|
||||
Generated cases are unavailable for this configuration.
|
||||
</strong>
|
||||
<span>
|
||||
{flavour === "pcre2"
|
||||
? "The current PCRE2 syntax provider is intentionally partial and does not expose a complete normalized AST. It is not reinterpreted as ECMAScript."
|
||||
: syntaxCurrent
|
||||
? "This flavour does not have an implemented generator."
|
||||
: "Wait for an accepted, current syntax result before generating."}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="generation-settings"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void generate();
|
||||
}}
|
||||
>
|
||||
<div className="generation-settings-primary">
|
||||
<label>
|
||||
<span>Deterministic seed</span>
|
||||
<input
|
||||
value={seed}
|
||||
maxLength={GENERATION_LIMITS.maximumSeedUtf16}
|
||||
disabled={running}
|
||||
required
|
||||
onChange={(event) => setSeed(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Retain at most</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumGeneratedCases}
|
||||
value={maximumCases}
|
||||
disabled={running}
|
||||
onChange={(event) => setMaximumCases(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Seeded variants</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumGeneratedCases}
|
||||
value={randomVariantCount}
|
||||
disabled={running}
|
||||
onChange={(event) => setRandomVariantCount(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Advanced generation bounds</summary>
|
||||
<div className="generation-settings-grid">
|
||||
<label>
|
||||
<span>Candidate attempts</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={GENERATION_LIMITS.maximumCandidateAttempts}
|
||||
value={maximumCandidateAttempts}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setMaximumCandidateAttempts(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Per-case timeout (ms)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={GENERATION_LIMITS.minimumPerCaseTimeoutMs}
|
||||
max={GENERATION_LIMITS.maximumPerCaseTimeoutMs}
|
||||
value={perCaseTimeoutMs}
|
||||
disabled={running}
|
||||
onChange={(event) => setPerCaseTimeoutMs(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Aggregate wall time (ms)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={GENERATION_LIMITS.maximumWallTimeMs}
|
||||
value={maximumWallTimeMs}
|
||||
disabled={running}
|
||||
onChange={(event) => setMaximumWallTimeMs(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Bytes per subject</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={GENERATION_LIMITS.maximumSubjectBytes}
|
||||
value={maximumSubjectBytes}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setMaximumSubjectBytes(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Aggregate subject bytes</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={GENERATION_LIMITS.maximumTotalSubjectBytes}
|
||||
value={maximumTotalSubjectBytes}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setMaximumTotalSubjectBytes(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Synthesized repetitions</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={GENERATION_LIMITS.maximumQuantifierRepetitions}
|
||||
value={maximumQuantifierRepetitions}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setMaximumQuantifierRepetitions(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<div className="generation-run-actions">
|
||||
{running ? (
|
||||
<button type="button" className="primary-button" onClick={cancel}>
|
||||
Cancel generation
|
||||
</button>
|
||||
) : (
|
||||
<button type="submit" className="primary-button">
|
||||
Generate & verify
|
||||
</button>
|
||||
)}
|
||||
<small>
|
||||
Hard caps:{" "}
|
||||
{DEFAULT_REGEX_LIMITS.maximumGeneratedCases.toLocaleString()}{" "}
|
||||
retained cases ·{" "}
|
||||
{GENERATION_LIMITS.maximumTotalSubjectBytes / 1024 / 1024} MiB
|
||||
candidate text · {GENERATION_LIMITS.maximumWallTimeMs / 1_000} s
|
||||
wall time
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`generation-status ${error ? "is-error" : ""}`}
|
||||
role={error ? "alert" : "status"}
|
||||
aria-live="polite"
|
||||
>
|
||||
<strong>{message}</strong>
|
||||
{progress ? (
|
||||
<>
|
||||
<progress
|
||||
aria-label="Generated-case progress"
|
||||
max={progressMaximum}
|
||||
value={progressValue}
|
||||
/>
|
||||
<span>
|
||||
{progress.phase} · {progress.completed.toLocaleString()} /{" "}
|
||||
{progress.total.toLocaleString()} ·{" "}
|
||||
{progress.retained.toLocaleString()} retained
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <span>{error}</span> : null}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="generation-results">
|
||||
<section
|
||||
className="generation-summary"
|
||||
aria-label="Generation summary"
|
||||
>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{result.status.replaceAll("-", " ")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Seed</dt>
|
||||
<dd>
|
||||
<code>{result.seed}</code> · {result.seedHash}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Actual engine</dt>
|
||||
<dd>
|
||||
{result.engine
|
||||
? `${result.engine.engineName} · ${result.engine.engineVersion}`
|
||||
: "No candidate reached engine verification"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Verified cases</dt>
|
||||
<dd>{result.cases.length.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Engine executions</dt>
|
||||
<dd>{result.engineExecutions.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Candidate bytes</dt>
|
||||
<dd>{result.generatedCandidateBytes.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Wall time</dt>
|
||||
<dd>{result.wallTimeMs.toFixed(1)} ms</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{result.warnings.length > 0 ? (
|
||||
<ul className="generation-warnings">
|
||||
{result.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="generation-coverage"
|
||||
aria-labelledby="generation-coverage-heading"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Claimed only for generator v1</p>
|
||||
<h3 id="generation-coverage-heading">Coverage report</h3>
|
||||
</div>
|
||||
</header>
|
||||
<ul>
|
||||
{result.coverage.map((entry) => (
|
||||
<li key={entry.feature}>
|
||||
<span className={`status status-${entry.status}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
<strong>{entry.feature.replaceAll("-", " ")}</strong>
|
||||
<span>{entry.detail}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{result.unsupportedConstructs.length > 0 ? (
|
||||
<details>
|
||||
<summary>
|
||||
{result.unsupportedConstructs.length.toLocaleString()}{" "}
|
||||
unsupported or partially synthesized construct(s)
|
||||
</summary>
|
||||
<ul className="unsupported-generation-list">
|
||||
{result.unsupportedConstructs.map((construct) => (
|
||||
<li key={construct.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
disabled={!onSelectPatternRange}
|
||||
onClick={() => onSelectPatternRange?.(construct.range)}
|
||||
>
|
||||
{construct.kind} · {construct.range.startUtf16}–
|
||||
{construct.range.endUtf16}
|
||||
</button>
|
||||
<code>{construct.rawPreview || "empty"}</code>
|
||||
<span>{construct.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="generation-cases"
|
||||
aria-labelledby="generated-cases-heading"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Exact verified subjects</p>
|
||||
<h3 id="generated-cases-heading">
|
||||
Retained cases{" "}
|
||||
<small>({result.cases.length.toLocaleString()})</small>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="generation-case-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={result.cases.length === 0}
|
||||
onClick={() =>
|
||||
setSelectedIds(
|
||||
selectedIds.size === result.cases.length
|
||||
? new Set()
|
||||
: new Set(
|
||||
result.cases.map((candidate) => candidate.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{selectedIds.size === result.cases.length
|
||||
? "Select none"
|
||||
: "Select all"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={result.cases.length === 0}
|
||||
onClick={() =>
|
||||
downloadCases(result, {
|
||||
flavour,
|
||||
...(flavourVersion === undefined
|
||||
? {}
|
||||
: { flavourVersion }),
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
scanAll,
|
||||
})
|
||||
}
|
||||
>
|
||||
Export verified JSON
|
||||
</button>
|
||||
{onAddToTests ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={selectedCases.length === 0}
|
||||
onClick={() => {
|
||||
try {
|
||||
onAddToTests(selectedCases);
|
||||
setError(undefined);
|
||||
setMessage(
|
||||
`Added ${selectedCases.length.toLocaleString()} generated cases to the unit-test suite with seed provenance.`,
|
||||
);
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Generated cases could not be added.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add selected to tests
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
{result.cases.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
No candidate satisfied its intended property within the
|
||||
configured bounds.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="generated-case-list">
|
||||
{result.cases.map((candidate) => (
|
||||
<li key={candidate.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${candidate.name}`}
|
||||
checked={selectedIds.has(candidate.id)}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(candidate.id);
|
||||
else next.delete(candidate.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>{candidate.name}</strong>
|
||||
<small>
|
||||
{candidate.expectation.replaceAll("-", " ")} ·{" "}
|
||||
{candidate.subjectBytes.toLocaleString()} bytes ·{" "}
|
||||
{candidate.elapsedMs.toFixed(2)} ms
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
<code>{subjectPreview(candidate.subject)}</code>
|
||||
<div className="generation-feature-chips">
|
||||
{candidate.features.map((feature) => (
|
||||
<span key={feature}>
|
||||
{feature.replaceAll("-", " ")}
|
||||
</span>
|
||||
))}
|
||||
<span>actual engine verified</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{result.discarded.length > 0 ? (
|
||||
<details className="generation-discarded">
|
||||
<summary>
|
||||
Advanced: {result.discarded.length.toLocaleString()} rejected
|
||||
candidate diagnostic(s)
|
||||
</summary>
|
||||
<p>
|
||||
These candidates are not tests. Subjects are clipped here; the
|
||||
diagnostic records only why each intended property failed.
|
||||
</p>
|
||||
<ul>
|
||||
{result.discarded.map((candidate) => (
|
||||
<li key={`${candidate.candidateId}-${candidate.reason}`}>
|
||||
<strong>{candidate.reason.replaceAll("-", " ")}</strong>
|
||||
<code>{subjectPreview(candidate.subjectPreview)}</code>
|
||||
<span>{candidate.detail}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
|
||||
import {
|
||||
DEFAULT_GENERATION_SETTINGS,
|
||||
GENERATION_LIMITS,
|
||||
} from "../regex/generation/generation-limits";
|
||||
import { manifest } from "../toolbox/manifest";
|
||||
|
||||
const releaseSourceUrl = `https://git.add-ideas.de/zemion/regex-tools/src/tag/v${manifest.version}`;
|
||||
@@ -43,10 +47,11 @@ export function HelpDialog({
|
||||
<section>
|
||||
<h3>What runs where</h3>
|
||||
<p>
|
||||
Syntax parsing and actual ECMAScript execution run in separate,
|
||||
killable browser workers. Patterns, subjects and projects are never
|
||||
uploaded. There is no backend, account, telemetry, CDN dependency,
|
||||
or executable replacement function.
|
||||
Syntax parsing and actual ECMAScript or PCRE2 execution run in
|
||||
separate, killable browser workers. PCRE2 is the bundled,
|
||||
self-hosted 10.47 WebAssembly engine. Patterns, subjects and
|
||||
projects are never uploaded. There is no backend, account,
|
||||
telemetry, CDN dependency, or executable replacement function.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
@@ -56,8 +61,47 @@ export function HelpDialog({
|
||||
syntax provider. The extraction tree contains actual browser-engine
|
||||
matches and captures. Replacement mode adds a typed token tree and
|
||||
bounded per-match contribution/range mapping over those actual
|
||||
matches. None is an internal V8, SpiderMonkey or JavaScriptCore
|
||||
execution trace.
|
||||
matches. These views are not internal engine traces. The separate
|
||||
PCRE trace viewer shows actual bounded automatic callouts reported
|
||||
by PCRE2; its adjacent-position movement labels are derived and it
|
||||
is not a complete record of every internal action. Browser
|
||||
ECMAScript exposes no corresponding V8, SpiderMonkey or
|
||||
JavaScriptCore trace API.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Advisory analysis</h3>
|
||||
<p>
|
||||
Static risk findings are ECMAScript-2025-specific heuristics, and
|
||||
generated-input growth observations describe only the selected
|
||||
bounded samples. Neither proves safety or general complexity. Growth
|
||||
and cold/warm benchmark timing run in a separate killable worker and
|
||||
never include the PCRE2 trace path.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Generated cases</h3>
|
||||
<p>
|
||||
ECMAScript generator v1 deterministically samples supported
|
||||
normalized-AST paths from an explicit seed. Every intended positive
|
||||
and negative subject runs through the selected actual engine before
|
||||
it can become a test; candidates with the wrong outcome are
|
||||
discarded. The coverage report names unsupported constructs.
|
||||
Sampling is not proof of complete language coverage. PCRE2 remains
|
||||
unavailable until its structural provider exposes the required AST;
|
||||
its syntax is never reinterpreted as ECMAScript.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Pattern formatting</h3>
|
||||
<p>
|
||||
The ECMAScript formatter makes only grammar-backed literal/control
|
||||
escape changes; it does not insert layout whitespace or reinterpret
|
||||
PCRE2 syntax. Applying a preview requires independent source and
|
||||
candidate reparsing, actual-engine match/replacement comparison,
|
||||
every applicable exact unit test, and an explicit confirmation.
|
||||
Passing bounded checks is evidence for those snapshots, not proof
|
||||
for every possible subject.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
@@ -105,6 +149,78 @@ export function HelpDialog({
|
||||
<dt>Unit-test suite wall time</dt>
|
||||
<dd>60 seconds</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Corpus input</dt>
|
||||
<dd>
|
||||
{DEFAULT_REGEX_LIMITS.maximumCorpusDocuments.toLocaleString()}{" "}
|
||||
documents · {DEFAULT_REGEX_LIMITS.corpusHardBytes / 1024 / 1024}{" "}
|
||||
MiB
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Corpus wall time</dt>
|
||||
<dd>
|
||||
{DEFAULT_REGEX_LIMITS.maximumCorpusWallTimeMs / 60_000} minutes
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Analysis wall time</dt>
|
||||
<dd>
|
||||
{DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs / 1_000}{" "}
|
||||
seconds
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Benchmark iteration cap</dt>
|
||||
<dd>
|
||||
{DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Growth probe steps</dt>
|
||||
<dd>24</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Generated cases</dt>
|
||||
<dd>
|
||||
{DEFAULT_REGEX_LIMITS.maximumGeneratedCases.toLocaleString()}{" "}
|
||||
retained ·{" "}
|
||||
{GENERATION_LIMITS.maximumCandidateAttempts.toLocaleString()}{" "}
|
||||
attempts
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Generation defaults</dt>
|
||||
<dd>
|
||||
{DEFAULT_GENERATION_SETTINGS.perCaseTimeoutMs} ms per case ·{" "}
|
||||
{DEFAULT_GENERATION_SETTINGS.maximumWallTimeMs / 1_000} seconds
|
||||
aggregate
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Generated subject text</dt>
|
||||
<dd>
|
||||
{GENERATION_LIMITS.maximumTotalSubjectBytes / 1024 / 1024} MiB
|
||||
aggregate hard cap
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Formatter validation</dt>
|
||||
<dd>1,000 tests · 60 seconds aggregate</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Independent corpus lines</dt>
|
||||
<dd>
|
||||
{DEFAULT_REGEX_LIMITS.maximumCorpusLines.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Corpus applied output</dt>
|
||||
<dd>
|
||||
{DEFAULT_REGEX_LIMITS.maximumCorpusOutputBytes / 1024 / 1024}{" "}
|
||||
MiB
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p>
|
||||
A timeout terminates the worker. It means “timed out”, never “no
|
||||
@@ -116,7 +232,15 @@ export function HelpDialog({
|
||||
<p>
|
||||
Ad-hoc test text is excluded from local saves and exports by
|
||||
default. Test definitions are deliberate project data. Import is
|
||||
validated and paused until you review and run it.
|
||||
validated and paused until you review and run it. Corpus documents,
|
||||
contents, outputs and results are always ephemeral and excluded from
|
||||
projects and IndexedDB. Analysis settings and results are likewise
|
||||
ephemeral. Generated-case settings and review results are ephemeral;
|
||||
only cases explicitly added to the test suite persist, together with
|
||||
their bounded seed and generator provenance. Test subjects remain
|
||||
excluded unless separately opted in. Formatter previews and
|
||||
validation observations are ephemeral; only an explicitly applied
|
||||
pattern enters workbench/project state.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
|
||||
241
src/components/MinimizePanel.css
Normal file
241
src/components/MinimizePanel.css
Normal file
@@ -0,0 +1,241 @@
|
||||
.minimize-dialog {
|
||||
width: min(90rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.minimize-panel {
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.minimizer-body {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.minimizer-advisory {
|
||||
margin: 0;
|
||||
border-left: 3px solid var(--toolbox-accent);
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.minimizer-config,
|
||||
.minimizer-result {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.8);
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.minimizer-config > header,
|
||||
.minimizer-result > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.minimizer-config h3,
|
||||
.minimizer-result h3,
|
||||
.minimizer-result header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.minimizer-config h3,
|
||||
.minimizer-result h3 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.minimizer-config > header span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.minimizer-control-grid,
|
||||
.minimizer-variant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.minimizer-variant-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.minimizer-control-grid label:not(.check-control),
|
||||
.minimizer-variant-grid label,
|
||||
.minimizer-subject,
|
||||
.minimizer-output {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.minimizer-wide-control {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.minimizer-check {
|
||||
min-height: 2.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.minimizer-subject {
|
||||
padding: 0 0.8rem 0.8rem;
|
||||
}
|
||||
|
||||
.minimizer-subject textarea {
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.minimizer-variant-grid textarea {
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
|
||||
.minimizer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.minimizer-actions p,
|
||||
.minimizer-status {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.minimizer-progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.minimizer-progress progress {
|
||||
width: 100%;
|
||||
accent-color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.minimizer-result > dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
margin: 0;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.minimizer-result > dl > div {
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.minimizer-result dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.minimizer-result dd {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.minimizer-result-badge {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 99rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.minimizer-result-badge.is-partial,
|
||||
.minimizer-result-badge.is-cancelled {
|
||||
color: var(--regex-warning);
|
||||
}
|
||||
|
||||
.minimizer-result-badge.is-unsupported {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.minimizer-observation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.minimizer-observation {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-left: 4px solid var(--toolbox-accent);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.7);
|
||||
padding: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.minimizer-observation.is-timeout {
|
||||
border-left-color: var(--regex-warning);
|
||||
}
|
||||
|
||||
.minimizer-observation.is-crash,
|
||||
.minimizer-observation.is-worker-error {
|
||||
border-left-color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.minimizer-observation span,
|
||||
.minimizer-observation p,
|
||||
.minimizer-observation small {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.minimizer-observation code {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.minimizer-output {
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.minimizer-output textarea {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.minimizer-result > .minimizer-actions {
|
||||
padding: 0 0.8rem 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.minimizer-variant-grid,
|
||||
.minimizer-observation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.minimizer-progress {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
184
src/components/MinimizePanel.test.tsx
Normal file
184
src/components/MinimizePanel.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
SubjectMinimizationProgress,
|
||||
SubjectMinimizationRequest,
|
||||
SubjectMinimizationResult,
|
||||
} from "../regex/minimization/minimization.types";
|
||||
import { MinimizePanel, type SubjectMinimizerClient } from "./MinimizePanel";
|
||||
|
||||
function result(): SubjectMinimizationResult {
|
||||
const observation = {
|
||||
status: "complete" as const,
|
||||
reproduced: true,
|
||||
fingerprint: ["expected-no-match:present"],
|
||||
summary: "The forbidden match is still present.",
|
||||
sides: [
|
||||
{
|
||||
flavour: "ecmascript" as const,
|
||||
status: "complete" as const,
|
||||
engineIdentity: "ecmascript · Fixture · 1 · utf16",
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
targetIdentity: "minimization-fixture",
|
||||
status: "complete",
|
||||
stopReason: "locally-minimal",
|
||||
locallyMinimal: true,
|
||||
originalSubject: "abXcd",
|
||||
minimizedSubject: "X",
|
||||
originalScalars: 5,
|
||||
minimizedScalars: 1,
|
||||
originalUtf8Bytes: 5,
|
||||
minimizedUtf8Bytes: 1,
|
||||
evaluations: 12,
|
||||
acceptedReductions: 3,
|
||||
elapsedMs: 4,
|
||||
budgets: {
|
||||
maximumEvaluations: 500,
|
||||
maximumWallTimeMs: 15_000,
|
||||
candidateTimeoutMs: 2_000,
|
||||
},
|
||||
baseline: observation,
|
||||
final: observation,
|
||||
retainedHistory: [],
|
||||
historyTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(client: SubjectMinimizerClient) {
|
||||
const onUseSubject = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MinimizePanel
|
||||
activeFlavour="ecmascript"
|
||||
activeFlavourVersion="ECMAScript 2025 syntax / current browser runtime"
|
||||
activeFlags={["g", "u"]}
|
||||
activeOptions={{}}
|
||||
pattern="X"
|
||||
subject="abXcd"
|
||||
replacement="!"
|
||||
scanAll
|
||||
timeoutMs={2_000}
|
||||
onUseSubject={onUseSubject}
|
||||
onClose={onClose}
|
||||
createMinimizer={() => client}
|
||||
/>,
|
||||
);
|
||||
return { onUseSubject, onClose };
|
||||
}
|
||||
|
||||
function completingClient(
|
||||
requests: SubjectMinimizationRequest[],
|
||||
): SubjectMinimizerClient {
|
||||
return {
|
||||
minimize: vi.fn(
|
||||
async (
|
||||
request: SubjectMinimizationRequest,
|
||||
onProgress?: (progress: SubjectMinimizationProgress) => void,
|
||||
) => {
|
||||
requests.push(request);
|
||||
onProgress?.({
|
||||
phase: "local-sweep",
|
||||
evaluations: 12,
|
||||
maximumEvaluations: 500,
|
||||
acceptedReductions: 3,
|
||||
currentScalars: 1,
|
||||
currentUtf8Bytes: 1,
|
||||
elapsedMs: 4,
|
||||
maximumWallTimeMs: 15_000,
|
||||
});
|
||||
return result();
|
||||
},
|
||||
),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("MinimizePanel", () => {
|
||||
it("runs a failing unit-test predicate and can apply the verified result", async () => {
|
||||
const requests: SubjectMinimizationRequest[] = [];
|
||||
const { onUseSubject } = renderPanel(completingClient(requests));
|
||||
|
||||
expect(
|
||||
screen.getByText(/does not claim a globally or semantically minimal/u),
|
||||
).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "Locally minimal under the documented transforms",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Minimized subject")).toHaveValue("X");
|
||||
expect(requests[0]?.target).toMatchObject({
|
||||
kind: "engine",
|
||||
oracle: {
|
||||
kind: "unit-test-failure",
|
||||
expectation: { kind: "should-not-match" },
|
||||
},
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Use minimized subject" }),
|
||||
);
|
||||
expect(onUseSubject).toHaveBeenCalledWith("X");
|
||||
});
|
||||
|
||||
it("exposes capture-range, timeout and two-engine mismatch targets", async () => {
|
||||
const requests: SubjectMinimizationRequest[] = [];
|
||||
renderPanel(completingClient(requests));
|
||||
const kind = screen.getByLabelText("Minimization failure kind");
|
||||
|
||||
expect(kind).toContainHTML("capture-range-mismatch");
|
||||
expect(kind).toContainHTML("engine-timeout");
|
||||
expect(kind).toContainHTML("comparison-mismatch");
|
||||
|
||||
fireEvent.change(kind, { target: { value: "engine-timeout" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
expect(requests[0]?.target).toMatchObject({
|
||||
kind: "engine",
|
||||
oracle: { kind: "engine-timeout" },
|
||||
});
|
||||
|
||||
fireEvent.change(kind, {
|
||||
target: { value: "comparison-mismatch" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
expect(requests[1]?.target).toMatchObject({
|
||||
kind: "comparison-mismatch",
|
||||
operation: "match",
|
||||
sides: [
|
||||
{ flavour: "ecmascript", pattern: "X" },
|
||||
{ flavour: "pcre2", pattern: "X" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the active worker set and closes independently", async () => {
|
||||
const client: SubjectMinimizerClient = {
|
||||
minimize: vi.fn(
|
||||
() => new Promise<SubjectMinimizationResult>(() => undefined),
|
||||
),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
const { onClose } = renderPanel(client);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Verify & minimize" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||
expect(client.cancel).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByText(/active syntax and engine workers were terminated/u),
|
||||
).toBeVisible();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Close subject minimizer" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
848
src/components/MinimizePanel.tsx
Normal file
848
src/components/MinimizePanel.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { writeClipboardText } from "../browser/clipboard";
|
||||
import {
|
||||
AVAILABLE_REGEX_FLAVOURS,
|
||||
defaultRegexOptions,
|
||||
} from "../regex/flavours/flavour-registry";
|
||||
import {
|
||||
DEFAULT_REGEX_LIMITS,
|
||||
utf8ByteLength,
|
||||
} from "../regex/execution/request-limits";
|
||||
import type {
|
||||
RegexEngineOptions,
|
||||
RegexFlavourId,
|
||||
} from "../regex/model/flavour";
|
||||
import type { RegexTestExpectation } from "../regex/tests/test-case.types";
|
||||
import { SubjectMinimizer } from "../regex/minimization/SubjectMinimizer";
|
||||
import type {
|
||||
EngineFailureOracle,
|
||||
SubjectMinimizationProgress,
|
||||
SubjectMinimizationRequest,
|
||||
SubjectMinimizationResult,
|
||||
} from "../regex/minimization/minimization.types";
|
||||
import "./MinimizePanel.css";
|
||||
|
||||
type MinimizationMode =
|
||||
| "unit-test-failure"
|
||||
| "capture-range-mismatch"
|
||||
| "engine-timeout"
|
||||
| "comparison-mismatch";
|
||||
|
||||
type UnitExpectationKind = RegexTestExpectation["kind"];
|
||||
|
||||
export interface SubjectMinimizerClient {
|
||||
minimize(
|
||||
request: SubjectMinimizationRequest,
|
||||
onProgress?: (progress: SubjectMinimizationProgress) => void,
|
||||
): Promise<SubjectMinimizationResult>;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface MinimizePanelProps {
|
||||
readonly activeFlavour: RegexFlavourId;
|
||||
readonly activeFlavourVersion: string;
|
||||
readonly activeFlags: readonly string[];
|
||||
readonly activeOptions: RegexEngineOptions;
|
||||
readonly pattern: string;
|
||||
readonly subject: string;
|
||||
readonly replacement: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly timeoutMs: number;
|
||||
readonly onUseSubject: (subject: string) => void;
|
||||
readonly onClose: () => void;
|
||||
readonly createMinimizer?: () => SubjectMinimizerClient;
|
||||
}
|
||||
|
||||
const ECMASCRIPT = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
|
||||
const PCRE2 = AVAILABLE_REGEX_FLAVOURS.require("pcre2");
|
||||
|
||||
function flagsFor(
|
||||
activeFlavour: RegexFlavourId,
|
||||
activeFlags: readonly string[],
|
||||
flavour: "ecmascript" | "pcre2",
|
||||
): readonly string[] {
|
||||
const definition = flavour === "ecmascript" ? ECMASCRIPT : PCRE2;
|
||||
return activeFlavour === flavour
|
||||
? definition.flags
|
||||
.map((flag) => flag.value)
|
||||
.filter((flag) => activeFlags.includes(flag))
|
||||
: definition.defaultFlags;
|
||||
}
|
||||
|
||||
function optionsFor(
|
||||
activeFlavour: RegexFlavourId,
|
||||
activeOptions: RegexEngineOptions,
|
||||
flavour: "ecmascript" | "pcre2",
|
||||
): RegexEngineOptions {
|
||||
if (activeFlavour === flavour) return activeOptions;
|
||||
return defaultRegexOptions(flavour === "ecmascript" ? ECMASCRIPT : PCRE2);
|
||||
}
|
||||
|
||||
function parseCaptureSelector(value: string): {
|
||||
readonly groupNumber?: number;
|
||||
readonly groupName?: string;
|
||||
} {
|
||||
const trimmed = value.trim();
|
||||
const number = Number(trimmed);
|
||||
return trimmed !== "" && Number.isSafeInteger(number) && number > 0
|
||||
? { groupNumber: number }
|
||||
: { groupName: trimmed };
|
||||
}
|
||||
|
||||
function resultLabel(result: SubjectMinimizationResult): string {
|
||||
if (result.locallyMinimal) {
|
||||
return "Locally minimal under the documented transforms";
|
||||
}
|
||||
return result.stopReason.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
function Observation({
|
||||
label,
|
||||
observation,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly observation: SubjectMinimizationResult["baseline"];
|
||||
}) {
|
||||
return (
|
||||
<article className={`minimizer-observation is-${observation.status}`}>
|
||||
<strong>{label}</strong>
|
||||
<span>
|
||||
{observation.status} ·{" "}
|
||||
{observation.reproduced ? "predicate reproduced" : "not reproduced"}
|
||||
</span>
|
||||
<p>{observation.summary}</p>
|
||||
{observation.fingerprint.length > 0 ? (
|
||||
<code>{observation.fingerprint.join(" · ")}</code>
|
||||
) : null}
|
||||
{observation.sides?.length ? (
|
||||
<small>
|
||||
{observation.sides
|
||||
.map(
|
||||
(side) =>
|
||||
`${side.flavour}: ${side.status}${
|
||||
side.engineIdentity ? ` · ${side.engineIdentity}` : ""
|
||||
}`,
|
||||
)
|
||||
.join(" | ")}
|
||||
</small>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MinimizePanel({
|
||||
activeFlavour,
|
||||
activeFlavourVersion,
|
||||
activeFlags,
|
||||
activeOptions,
|
||||
pattern: initialPattern,
|
||||
subject: initialSubject,
|
||||
replacement: initialReplacement,
|
||||
scanAll: initialScanAll,
|
||||
timeoutMs: initialTimeoutMs,
|
||||
onUseSubject,
|
||||
onClose,
|
||||
createMinimizer = () => new SubjectMinimizer(),
|
||||
}: MinimizePanelProps) {
|
||||
const [mode, setMode] = useState<MinimizationMode>("unit-test-failure");
|
||||
const [subject, setSubject] = useState(initialSubject);
|
||||
const [scanAll, setScanAll] = useState(initialScanAll);
|
||||
const [unitKind, setUnitKind] =
|
||||
useState<UnitExpectationKind>("should-not-match");
|
||||
const [expectedCount, setExpectedCount] = useState(1);
|
||||
const [matchIndex, setMatchIndex] = useState(0);
|
||||
const [expectedValue, setExpectedValue] = useState("");
|
||||
const [captureSelector, setCaptureSelector] = useState("1");
|
||||
const [captureStatus, setCaptureStatus] = useState<
|
||||
"participated" | "did-not-participate" | "matched-empty"
|
||||
>("participated");
|
||||
const [checkCaptureValue, setCheckCaptureValue] = useState(false);
|
||||
const [expectedStart, setExpectedStart] = useState(0);
|
||||
const [expectedEnd, setExpectedEnd] = useState(0);
|
||||
const [durationLimitMs, setDurationLimitMs] = useState(10);
|
||||
const [comparisonOperation, setComparisonOperation] = useState<
|
||||
"match" | "replace"
|
||||
>("match");
|
||||
const [ecmaPattern, setEcmaPattern] = useState(initialPattern);
|
||||
const [pcrePattern, setPcrePattern] = useState(initialPattern);
|
||||
const [ecmaReplacement, setEcmaReplacement] = useState(initialReplacement);
|
||||
const [pcreReplacement, setPcreReplacement] = useState(initialReplacement);
|
||||
const [maximumEvaluations, setMaximumEvaluations] = useState(500);
|
||||
const [maximumWallTimeMs, setMaximumWallTimeMs] = useState(15_000);
|
||||
const [candidateTimeoutMs, setCandidateTimeoutMs] = useState(
|
||||
Math.min(
|
||||
DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
|
||||
Math.max(1, initialTimeoutMs),
|
||||
),
|
||||
);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [status, setStatus] = useState(
|
||||
"Select an exact failure predicate, then verify the baseline.",
|
||||
);
|
||||
const [progress, setProgress] = useState<SubjectMinimizationProgress>();
|
||||
const [result, setResult] = useState<SubjectMinimizationResult>();
|
||||
const [copyStatus, setCopyStatus] = useState("");
|
||||
const minimizer = useRef<SubjectMinimizerClient | null>(null);
|
||||
const revision = useRef(0);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
revision.current += 1;
|
||||
minimizer.current?.dispose();
|
||||
minimizer.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const ecmaFlags = useMemo(
|
||||
() => flagsFor(activeFlavour, activeFlags, "ecmascript"),
|
||||
[activeFlags, activeFlavour],
|
||||
);
|
||||
const pcreFlags = useMemo(
|
||||
() => flagsFor(activeFlavour, activeFlags, "pcre2"),
|
||||
[activeFlags, activeFlavour],
|
||||
);
|
||||
const ecmaOptions = useMemo(
|
||||
() => optionsFor(activeFlavour, activeOptions, "ecmascript"),
|
||||
[activeFlavour, activeOptions],
|
||||
);
|
||||
const pcreOptions = useMemo(
|
||||
() => optionsFor(activeFlavour, activeOptions, "pcre2"),
|
||||
[activeFlavour, activeOptions],
|
||||
);
|
||||
|
||||
const unitExpectation = (): RegexTestExpectation => {
|
||||
switch (unitKind) {
|
||||
case "should-match":
|
||||
case "should-not-match":
|
||||
case "must-time-out":
|
||||
return { kind: unitKind };
|
||||
case "match-count":
|
||||
return { kind: unitKind, count: expectedCount };
|
||||
case "full-match":
|
||||
return {
|
||||
kind: unitKind,
|
||||
matchIndex,
|
||||
value: expectedValue,
|
||||
};
|
||||
case "capture":
|
||||
return {
|
||||
kind: unitKind,
|
||||
matchIndex,
|
||||
...parseCaptureSelector(captureSelector),
|
||||
status: captureStatus,
|
||||
...(checkCaptureValue ? { value: expectedValue } : {}),
|
||||
};
|
||||
case "replacement":
|
||||
return { kind: unitKind, expected: expectedValue };
|
||||
case "must-complete-within":
|
||||
return { kind: unitKind, milliseconds: durationLimitMs };
|
||||
}
|
||||
};
|
||||
|
||||
const activeSide = {
|
||||
flavour:
|
||||
activeFlavour === "pcre2" ? ("pcre2" as const) : ("ecmascript" as const),
|
||||
flavourVersion: activeFlavourVersion,
|
||||
pattern: initialPattern,
|
||||
flags: activeFlags,
|
||||
options: activeOptions,
|
||||
...(unitKind === "replacement" ? { replacement: initialReplacement } : {}),
|
||||
};
|
||||
|
||||
const request = (): SubjectMinimizationRequest => {
|
||||
let target: SubjectMinimizationRequest["target"];
|
||||
if (mode === "comparison-mismatch") {
|
||||
target = {
|
||||
kind: "comparison-mismatch",
|
||||
operation: comparisonOperation,
|
||||
scanAll,
|
||||
sides: [
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: ECMASCRIPT.defaultVersion,
|
||||
pattern: ecmaPattern,
|
||||
flags: ecmaFlags,
|
||||
options: ecmaOptions,
|
||||
...(comparisonOperation === "replace"
|
||||
? { replacement: ecmaReplacement }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
flavour: "pcre2",
|
||||
flavourVersion: PCRE2.defaultVersion,
|
||||
pattern: pcrePattern,
|
||||
flags: pcreFlags,
|
||||
options: pcreOptions,
|
||||
...(comparisonOperation === "replace"
|
||||
? { replacement: pcreReplacement }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
let oracle: EngineFailureOracle;
|
||||
if (mode === "engine-timeout") {
|
||||
oracle = { kind: "engine-timeout" };
|
||||
} else if (mode === "capture-range-mismatch") {
|
||||
oracle = {
|
||||
kind: "capture-range-mismatch",
|
||||
matchIndex,
|
||||
...parseCaptureSelector(captureSelector),
|
||||
expectedRange: {
|
||||
startUtf16: expectedStart,
|
||||
endUtf16: expectedEnd,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
oracle = {
|
||||
kind: "unit-test-failure",
|
||||
expectation: unitExpectation(),
|
||||
};
|
||||
}
|
||||
target = {
|
||||
kind: "engine",
|
||||
side: activeSide,
|
||||
scanAll,
|
||||
oracle,
|
||||
};
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
subject,
|
||||
target,
|
||||
budgets: {
|
||||
maximumEvaluations,
|
||||
maximumWallTimeMs,
|
||||
candidateTimeoutMs,
|
||||
},
|
||||
maximumMatches: 1_000,
|
||||
maximumCaptureRows: 10_000,
|
||||
maximumOutputBytes: 1024 * 1024,
|
||||
};
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const currentRevision = ++revision.current;
|
||||
setRunning(true);
|
||||
setResult(undefined);
|
||||
setCopyStatus("");
|
||||
setStatus("Verifying the baseline with the selected real engine worker…");
|
||||
try {
|
||||
minimizer.current ??= createMinimizer();
|
||||
const minimized = await minimizer.current.minimize(request(), (value) => {
|
||||
if (currentRevision !== revision.current) return;
|
||||
setProgress(value);
|
||||
setStatus(
|
||||
`${value.phase.replaceAll("-", " ")} · ${value.evaluations}/${value.maximumEvaluations} evaluations · ${value.acceptedReductions} accepted`,
|
||||
);
|
||||
});
|
||||
if (currentRevision !== revision.current) return;
|
||||
setResult(minimized);
|
||||
setStatus(resultLabel(minimized));
|
||||
} catch (error) {
|
||||
if (currentRevision !== revision.current) return;
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (currentRevision === revision.current) setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
revision.current += 1;
|
||||
minimizer.current?.cancel();
|
||||
setRunning(false);
|
||||
setStatus("Cancelled; active syntax and engine workers were terminated.");
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="panel minimize-panel"
|
||||
aria-labelledby="minimize-heading"
|
||||
>
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Bounded failing-subject reduction</p>
|
||||
<h2 id="minimize-heading">Minimize a reproducer</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close subject minimizer"
|
||||
data-dialog-initial-focus
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="minimizer-body">
|
||||
<p className="minimizer-advisory">
|
||||
This reducer proves only local minimality under deterministic
|
||||
Unicode-scalar chunk deletion, single-scalar deletion and canonical
|
||||
replacement. It does not claim a globally or semantically minimal
|
||||
example.
|
||||
</p>
|
||||
|
||||
<section className="minimizer-config" aria-labelledby="minimize-target">
|
||||
<header>
|
||||
<h3 id="minimize-target">Exact predicate</h3>
|
||||
<span>
|
||||
{activeFlavour} · {utf8ByteLength(subject).toLocaleString()} bytes
|
||||
</span>
|
||||
</header>
|
||||
<div className="minimizer-control-grid">
|
||||
<label>
|
||||
<span>Failure kind</span>
|
||||
<select
|
||||
aria-label="Minimization failure kind"
|
||||
value={mode}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setMode(event.target.value as MinimizationMode)
|
||||
}
|
||||
>
|
||||
<option value="unit-test-failure">
|
||||
Failing unit-test assertion
|
||||
</option>
|
||||
<option value="capture-range-mismatch">
|
||||
Wrong capture range
|
||||
</option>
|
||||
<option value="engine-timeout">Exact engine timeout</option>
|
||||
<option value="comparison-mismatch">
|
||||
ECMAScript ↔ PCRE2 mismatch
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="check-control minimizer-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scanAll}
|
||||
disabled={running}
|
||||
onChange={(event) => setScanAll(event.target.checked)}
|
||||
/>
|
||||
Scan all
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === "unit-test-failure" ? (
|
||||
<div className="minimizer-control-grid">
|
||||
<label>
|
||||
<span>Saved assertion</span>
|
||||
<select
|
||||
aria-label="Unit-test expectation"
|
||||
value={unitKind}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setUnitKind(event.target.value as UnitExpectationKind)
|
||||
}
|
||||
>
|
||||
<option value="should-match">Should match</option>
|
||||
<option value="should-not-match">Should not match</option>
|
||||
<option value="match-count">Exact match count</option>
|
||||
<option value="full-match">Full-match value</option>
|
||||
<option value="capture">Capture status/value</option>
|
||||
<option value="replacement">Replacement output</option>
|
||||
<option value="must-complete-within">
|
||||
Must complete within
|
||||
</option>
|
||||
<option value="must-time-out">Must time out</option>
|
||||
</select>
|
||||
</label>
|
||||
{unitKind === "match-count" ? (
|
||||
<label>
|
||||
<span>Expected count</span>
|
||||
<input
|
||||
type="number"
|
||||
value={expectedCount}
|
||||
min={0}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumMatches}
|
||||
onChange={(event) =>
|
||||
setExpectedCount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{unitKind === "full-match" || unitKind === "capture" ? (
|
||||
<label>
|
||||
<span>Zero-based match index</span>
|
||||
<input
|
||||
type="number"
|
||||
value={matchIndex}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
setMatchIndex(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{unitKind === "capture" ? (
|
||||
<>
|
||||
<label>
|
||||
<span>Capture number or name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={captureSelector}
|
||||
onChange={(event) =>
|
||||
setCaptureSelector(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Expected participation</span>
|
||||
<select
|
||||
value={captureStatus}
|
||||
onChange={(event) =>
|
||||
setCaptureStatus(
|
||||
event.target.value as typeof captureStatus,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="participated">Participated</option>
|
||||
<option value="did-not-participate">
|
||||
Did not participate
|
||||
</option>
|
||||
<option value="matched-empty">Matched empty</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="check-control minimizer-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkCaptureValue}
|
||||
onChange={(event) =>
|
||||
setCheckCaptureValue(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Assert value too
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
{unitKind === "full-match" ||
|
||||
unitKind === "replacement" ||
|
||||
(unitKind === "capture" && checkCaptureValue) ? (
|
||||
<label className="minimizer-wide-control">
|
||||
<span>Expected value</span>
|
||||
<input
|
||||
type="text"
|
||||
value={expectedValue}
|
||||
onChange={(event) => setExpectedValue(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{unitKind === "must-complete-within" ? (
|
||||
<label>
|
||||
<span>Assertion milliseconds</span>
|
||||
<input
|
||||
type="number"
|
||||
value={durationLimitMs}
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
setDurationLimitMs(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "capture-range-mismatch" ? (
|
||||
<div className="minimizer-control-grid">
|
||||
<label>
|
||||
<span>Zero-based match index</span>
|
||||
<input
|
||||
type="number"
|
||||
value={matchIndex}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
setMatchIndex(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Capture number or name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={captureSelector}
|
||||
onChange={(event) => setCaptureSelector(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Expected UTF-16 start</span>
|
||||
<input
|
||||
type="number"
|
||||
value={expectedStart}
|
||||
min={0}
|
||||
max={subject.length}
|
||||
onChange={(event) =>
|
||||
setExpectedStart(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Expected UTF-16 end</span>
|
||||
<input
|
||||
type="number"
|
||||
value={expectedEnd}
|
||||
min={0}
|
||||
max={subject.length}
|
||||
onChange={(event) =>
|
||||
setExpectedEnd(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "comparison-mismatch" ? (
|
||||
<>
|
||||
<div className="minimizer-control-grid">
|
||||
<label>
|
||||
<span>Operation</span>
|
||||
<select
|
||||
value={comparisonOperation}
|
||||
onChange={(event) =>
|
||||
setComparisonOperation(
|
||||
event.target.value as "match" | "replace",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="match">Match</option>
|
||||
<option value="replace">Replace</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="minimizer-variant-grid">
|
||||
<label>
|
||||
<span>ECMAScript pattern</span>
|
||||
<textarea
|
||||
value={ecmaPattern}
|
||||
onChange={(event) => setEcmaPattern(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>PCRE2 pattern</span>
|
||||
<textarea
|
||||
value={pcrePattern}
|
||||
onChange={(event) => setPcrePattern(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{comparisonOperation === "replace" ? (
|
||||
<>
|
||||
<label>
|
||||
<span>ECMAScript replacement</span>
|
||||
<textarea
|
||||
value={ecmaReplacement}
|
||||
onChange={(event) =>
|
||||
setEcmaReplacement(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>PCRE2 replacement</span>
|
||||
<textarea
|
||||
value={pcreReplacement}
|
||||
onChange={(event) =>
|
||||
setPcreReplacement(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<label className="minimizer-subject">
|
||||
<span>Failing subject · local text only</span>
|
||||
<textarea
|
||||
aria-label="Subject to minimize"
|
||||
value={subject}
|
||||
disabled={running}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="minimizer-config" aria-labelledby="minimize-bounds">
|
||||
<header>
|
||||
<h3 id="minimize-bounds">Hard bounds</h3>
|
||||
<span>Fixed deadline per candidate</span>
|
||||
</header>
|
||||
<div className="minimizer-control-grid">
|
||||
<label>
|
||||
<span>Candidate evaluations</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maximumEvaluations}
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumMinimizerRuns}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setMaximumEvaluations(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Aggregate wall time ms</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maximumWallTimeMs}
|
||||
min={1}
|
||||
max={60_000}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setMaximumWallTimeMs(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Each candidate ms</span>
|
||||
<input
|
||||
type="number"
|
||||
value={candidateTimeoutMs}
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs}
|
||||
disabled={running}
|
||||
onChange={(event) =>
|
||||
setCandidateTimeoutMs(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="minimizer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{running ? "Minimizing…" : "Verify & minimize"}
|
||||
</button>
|
||||
{running ? (
|
||||
<button type="button" className="secondary-button" onClick={cancel}>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
<p className="minimizer-status" aria-live="polite">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{progress ? (
|
||||
<div className="minimizer-progress">
|
||||
<progress
|
||||
aria-label="Minimizer evaluation progress"
|
||||
value={progress.evaluations}
|
||||
max={progress.maximumEvaluations}
|
||||
/>
|
||||
<span>
|
||||
{progress.currentScalars.toLocaleString()} scalars ·{" "}
|
||||
{progress.currentUtf8Bytes.toLocaleString()} bytes ·{" "}
|
||||
{progress.elapsedMs.toFixed(0)}/
|
||||
{progress.maximumWallTimeMs.toLocaleString()} ms
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result ? (
|
||||
<section
|
||||
className="minimizer-result"
|
||||
aria-labelledby="minimize-result"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Exact target {result.targetIdentity}</p>
|
||||
<h3 id="minimize-result">{resultLabel(result)}</h3>
|
||||
</div>
|
||||
<span className={`minimizer-result-badge is-${result.status}`}>
|
||||
{result.status}
|
||||
</span>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Scalars</dt>
|
||||
<dd>
|
||||
{result.originalScalars.toLocaleString()} →{" "}
|
||||
{result.minimizedScalars.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>UTF-8 bytes</dt>
|
||||
<dd>
|
||||
{result.originalUtf8Bytes.toLocaleString()} →{" "}
|
||||
{result.minimizedUtf8Bytes.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Evaluations</dt>
|
||||
<dd>
|
||||
{result.evaluations.toLocaleString()} /{" "}
|
||||
{result.budgets.maximumEvaluations.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Accepted reductions</dt>
|
||||
<dd>{result.acceptedReductions.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Wall time</dt>
|
||||
<dd>{result.elapsedMs.toFixed(1)} ms</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stop reason</dt>
|
||||
<dd>{result.stopReason.replaceAll("-", " ")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="minimizer-observation-grid">
|
||||
<Observation
|
||||
label="Baseline verification"
|
||||
observation={result.baseline}
|
||||
/>
|
||||
<Observation
|
||||
label="Final accepted candidate"
|
||||
observation={result.final}
|
||||
/>
|
||||
</div>
|
||||
<label className="minimizer-output">
|
||||
<span>Minimized subject</span>
|
||||
<textarea
|
||||
aria-label="Minimized subject"
|
||||
readOnly
|
||||
value={result.minimizedSubject}
|
||||
/>
|
||||
</label>
|
||||
<div className="minimizer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={!result.final.reproduced}
|
||||
onClick={() => onUseSubject(result.minimizedSubject)}
|
||||
>
|
||||
Use minimized subject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
void writeClipboardText(result.minimizedSubject)
|
||||
.then(() => setCopyStatus("Copied locally."))
|
||||
.catch((error: unknown) =>
|
||||
setCopyStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<p aria-live="polite">{copyStatus}</p>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
71
src/components/ModalDialog.tsx
Normal file
71
src/components/ModalDialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
export function ModalDialog({
|
||||
id,
|
||||
open,
|
||||
labelledBy,
|
||||
onClose,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
readonly id: string;
|
||||
readonly open: boolean;
|
||||
readonly labelledBy: string;
|
||||
readonly onClose: () => void;
|
||||
readonly children: ReactNode;
|
||||
readonly className?: string;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
|
||||
if (open && !element.open) {
|
||||
if (typeof element.showModal === "function") {
|
||||
element.showModal();
|
||||
} else {
|
||||
// jsdom and older browsers can still expose the content for a usable
|
||||
// non-modal fallback.
|
||||
element.setAttribute("open", "");
|
||||
}
|
||||
(
|
||||
element.querySelector<HTMLElement>("[data-dialog-initial-focus]") ??
|
||||
element
|
||||
).focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && element.open) {
|
||||
if (typeof element.close === "function") {
|
||||
element.close();
|
||||
} else {
|
||||
element.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
id={id}
|
||||
className={`app-dialog auxiliary-dialog ${className}`.trim()}
|
||||
aria-labelledby={labelledBy}
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
onClose={onClose}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
342
src/components/PatternFormatterPanel.css
Normal file
342
src/components/PatternFormatterPanel.css
Normal file
@@ -0,0 +1,342 @@
|
||||
.formatter-dialog {
|
||||
width: min(90rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.formatter-panel {
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.formatter-body {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.formatter-advisory,
|
||||
.formatter-unavailable,
|
||||
.formatter-empty,
|
||||
.formatter-result-note {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.formatter-advisory {
|
||||
border-left: 3px solid var(--toolbox-accent);
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--toolbox-accent-soft);
|
||||
}
|
||||
|
||||
.formatter-unavailable {
|
||||
margin: 0.8rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-danger) 45%, var(--toolbox-border));
|
||||
border-radius: calc(var(--toolbox-radius) * 0.72);
|
||||
padding: 0.8rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 8%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: color-mix(in srgb, var(--toolbox-danger) 78%, var(--toolbox-text));
|
||||
}
|
||||
|
||||
.formatter-preview,
|
||||
.formatter-validation {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.82);
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.formatter-preview > header,
|
||||
.formatter-validation > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.formatter-preview > header p,
|
||||
.formatter-preview > header h3,
|
||||
.formatter-validation > header p,
|
||||
.formatter-validation > header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.formatter-preview > header h3,
|
||||
.formatter-validation > header h3 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.formatter-preview > header > span,
|
||||
.formatter-validation > header > span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.formatter-pattern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.formatter-pattern-grid article {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.4rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.formatter-pattern-grid pre {
|
||||
min-height: 5rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.66);
|
||||
padding: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-text);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.formatter-table-scroll {
|
||||
overflow: auto;
|
||||
margin: 0 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.66);
|
||||
}
|
||||
|
||||
.formatter-table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.formatter-table-scroll th,
|
||||
.formatter-table-scroll td {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.formatter-table-scroll th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
|
||||
.formatter-table-scroll code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.formatter-table-scroll > p,
|
||||
.formatter-empty {
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.formatter-notices {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0 1.8rem 0.8rem 2.2rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.formatter-status {
|
||||
margin: 0 0.8rem;
|
||||
border-left: 3px solid var(--toolbox-accent);
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.formatter-status.is-different {
|
||||
border-left-color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.formatter-status.is-inconclusive {
|
||||
border-left-color: var(--regex-warning);
|
||||
}
|
||||
|
||||
.formatter-status p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.formatter-progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0 0.8rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.formatter-progress progress {
|
||||
width: 100%;
|
||||
accent-color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.formatter-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.8rem 0.8rem;
|
||||
}
|
||||
|
||||
.formatter-result {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.formatter-result > dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
margin: 0;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.formatter-result > dl > div {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.formatter-result dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.formatter-result dd {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.formatter-checks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.formatter-check {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-left: 4px solid var(--toolbox-accent);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.7);
|
||||
padding: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.formatter-check.is-different {
|
||||
border-left-color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.formatter-check.is-inconclusive {
|
||||
border-left-color: var(--regex-warning);
|
||||
}
|
||||
|
||||
.formatter-check header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.formatter-check header span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.formatter-check p,
|
||||
.formatter-check small {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.71rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.formatter-check code {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.formatter-result-note {
|
||||
padding: 0 0.8rem;
|
||||
}
|
||||
|
||||
.formatter-differences {
|
||||
margin: 0 0.8rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-danger) 35%, var(--toolbox-border));
|
||||
border-radius: calc(var(--toolbox-radius) * 0.7);
|
||||
padding: 0.7rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 6%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.formatter-differences h4 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.formatter-differences ul {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.71rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.formatter-confirmation {
|
||||
margin: 0 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: calc(var(--toolbox-radius) * 0.7);
|
||||
padding: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.formatter-pattern-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.formatter-progress {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
245
src/components/PatternFormatterPanel.test.tsx
Normal file
245
src/components/PatternFormatterPanel.test.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EcmaScriptSyntaxProvider } from "../regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
|
||||
import type {
|
||||
PatternFormatSnapshotCheck,
|
||||
PatternFormatValidationResult,
|
||||
} from "../regex/formatting/formatting.types";
|
||||
import {
|
||||
PatternFormatterPanel,
|
||||
type PatternFormatValidatorClient,
|
||||
type PatternFormatterPanelProps,
|
||||
} from "./PatternFormatterPanel";
|
||||
|
||||
const provider = new EcmaScriptSyntaxProvider();
|
||||
|
||||
function sameCheck(): PatternFormatSnapshotCheck {
|
||||
return {
|
||||
comparison: "same",
|
||||
before: {
|
||||
status: "complete",
|
||||
engineIdentity: "Native ECMAScript RegExp · fixture",
|
||||
message: "Source completed.",
|
||||
},
|
||||
after: {
|
||||
status: "complete",
|
||||
engineIdentity: "Native ECMAScript RegExp · fixture",
|
||||
message: "Candidate completed.",
|
||||
},
|
||||
summary: "Exact normalized match and replacement output is unchanged.",
|
||||
mismatchKinds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function validationResult(
|
||||
overrides: Partial<PatternFormatValidationResult> = {},
|
||||
): PatternFormatValidationResult {
|
||||
return {
|
||||
status: "equivalent",
|
||||
canApply: true,
|
||||
sourceSyntaxAccepted: true,
|
||||
formattedSyntaxAccepted: true,
|
||||
captureShapePreserved: true,
|
||||
current: sameCheck(),
|
||||
tests: [],
|
||||
applicableTestCount: 0,
|
||||
skippedTestCount: 0,
|
||||
completedTestCount: 0,
|
||||
differences: [],
|
||||
elapsedMs: 3,
|
||||
notices: [
|
||||
"No enabled unit test has the exact active pattern; the current snapshot was compared.",
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function validator(
|
||||
result: PatternFormatValidationResult = validationResult(),
|
||||
): PatternFormatValidatorClient {
|
||||
return {
|
||||
validate: vi.fn().mockImplementation(async (_input, onProgress) => {
|
||||
onProgress?.(0, 0);
|
||||
return result;
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function renderFormatter(
|
||||
overrides: Partial<PatternFormatterPanelProps> = {},
|
||||
result: PatternFormatValidationResult = validationResult(),
|
||||
) {
|
||||
const pattern = overrides.pattern ?? "a/b";
|
||||
const syntax =
|
||||
overrides.syntax ??
|
||||
(await provider.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "2025",
|
||||
pattern,
|
||||
flags: [],
|
||||
options: {},
|
||||
}));
|
||||
const currentValidator = validator(result);
|
||||
const onApply = vi.fn();
|
||||
const rendered = render(
|
||||
<PatternFormatterPanel
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
pattern={pattern}
|
||||
flags={[]}
|
||||
options={{}}
|
||||
syntax={syntax}
|
||||
subject="a/b a/b"
|
||||
replacement="[$&]"
|
||||
scanAll
|
||||
timeoutMs={2_000}
|
||||
tests={[]}
|
||||
onApply={onApply}
|
||||
createValidator={() => currentValidator}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { ...rendered, currentValidator, onApply, syntax };
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PatternFormatterPanel", () => {
|
||||
it("shows an exact preview and requires validation plus confirmation before apply", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { currentValidator, onApply } = await renderFormatter();
|
||||
|
||||
expect(screen.getByTestId("formatted-pattern-preview").textContent).toBe(
|
||||
"a\\/b",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Apply formatted pattern" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Validate exact snapshots" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(currentValidator.validate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"All completed safety gates retained the same observable behavior.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("checkbox")).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply formatted pattern" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Apply formatted pattern" }),
|
||||
);
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith("a\\/b");
|
||||
});
|
||||
|
||||
it("keeps a behavior-changing candidate blocked", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderFormatter(
|
||||
{},
|
||||
validationResult({
|
||||
status: "different",
|
||||
canApply: false,
|
||||
current: {
|
||||
...sameCheck(),
|
||||
comparison: "different",
|
||||
summary: "Match count changed.",
|
||||
mismatchKinds: ["match-count"],
|
||||
},
|
||||
differences: [
|
||||
{
|
||||
scope: "current-subject",
|
||||
code: "current-behavior-changed",
|
||||
summary: "Match count changed.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Validate exact snapshots" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText("current-behavior-changed")).toBeVisible();
|
||||
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Apply formatted pattern" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("reports PCRE2 and stale syntax snapshots as unavailable", async () => {
|
||||
const { rerender, syntax } = await renderFormatter({
|
||||
flavour: "pcre2",
|
||||
syntax: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/currently available only for ECMAScript/),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Validate exact snapshots" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PatternFormatterPanel
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
pattern="a/c"
|
||||
flags={[]}
|
||||
options={{}}
|
||||
syntax={syntax}
|
||||
subject=""
|
||||
replacement=""
|
||||
scanAll={false}
|
||||
timeoutMs={2_000}
|
||||
tests={[]}
|
||||
onApply={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/accepted, non-recovered syntax-provider snapshot/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("clears a validation result when an exact input snapshot changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const rendered = await renderFormatter();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Validate exact snapshots" }),
|
||||
);
|
||||
expect(await screen.findByRole("checkbox")).toBeVisible();
|
||||
|
||||
rendered.rerender(
|
||||
<PatternFormatterPanel
|
||||
flavour="ecmascript"
|
||||
flavourVersion="2025"
|
||||
pattern="a/b"
|
||||
flags={[]}
|
||||
options={{}}
|
||||
syntax={rendered.syntax}
|
||||
subject="different"
|
||||
replacement="[$&]"
|
||||
scanAll
|
||||
timeoutMs={2_000}
|
||||
tests={[]}
|
||||
onApply={rendered.onApply}
|
||||
createValidator={() => rendered.currentValidator}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Validate exact snapshots" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
617
src/components/PatternFormatterPanel.tsx
Normal file
617
src/components/PatternFormatterPanel.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
RegexEngineOptions,
|
||||
RegexFlavourId,
|
||||
} from "../regex/model/flavour";
|
||||
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
||||
import type { RegexTestCase } from "../regex/tests/test-case.types";
|
||||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||||
import { formatEcmaScriptPattern } from "../regex/formatting/ecmascript-format";
|
||||
import { PatternFormatValidator } from "../regex/formatting/PatternFormatValidator";
|
||||
import type {
|
||||
PatternFormatPreview,
|
||||
PatternFormatSnapshotCheck,
|
||||
PatternFormatValidationInput,
|
||||
PatternFormatValidationResult,
|
||||
} from "../regex/formatting/formatting.types";
|
||||
import "./PatternFormatterPanel.css";
|
||||
|
||||
const MAXIMUM_VISIBLE_CHANGES = 200;
|
||||
const MAXIMUM_VISIBLE_CHECKS = 200;
|
||||
|
||||
export interface PatternFormatValidatorClient {
|
||||
validate(
|
||||
input: PatternFormatValidationInput,
|
||||
onProgress?: (completed: number, total: number) => void,
|
||||
): Promise<PatternFormatValidationResult>;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface PatternFormatterPanelProps {
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly flavourVersion?: string;
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly syntax?: RegexSyntaxResult;
|
||||
readonly subject: string;
|
||||
readonly replacement: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly timeoutMs: number;
|
||||
readonly tests: readonly RegexTestCase[];
|
||||
readonly onApply: (formattedPattern: string) => void;
|
||||
readonly onClose?: () => void;
|
||||
readonly createValidator?: () => PatternFormatValidatorClient;
|
||||
}
|
||||
|
||||
type PreviewState =
|
||||
| { readonly status: "available"; readonly preview: PatternFormatPreview }
|
||||
| { readonly status: "unavailable"; readonly reason: string };
|
||||
|
||||
interface ValidationConfiguration {
|
||||
readonly flavourVersion?: string;
|
||||
readonly preview: PatternFormatPreview;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly subject: string;
|
||||
readonly replacement: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly timeoutMs: number;
|
||||
readonly tests: readonly RegexTestCase[];
|
||||
}
|
||||
|
||||
interface ValidationRecord {
|
||||
readonly configuration: ValidationConfiguration;
|
||||
readonly result: PatternFormatValidationResult;
|
||||
}
|
||||
|
||||
interface ConfirmationRecord {
|
||||
readonly configuration: ValidationConfiguration;
|
||||
readonly checked: boolean;
|
||||
}
|
||||
|
||||
function previewState(
|
||||
flavour: RegexFlavourId,
|
||||
pattern: string,
|
||||
syntax: RegexSyntaxResult | undefined,
|
||||
): PreviewState {
|
||||
if (flavour !== "ecmascript") {
|
||||
return {
|
||||
status: "unavailable",
|
||||
reason:
|
||||
"Canonical formatting is currently available only for ECMAScript. PCRE2 and other flavours remain unchanged because no complete grammar-backed formatter is implemented for them.",
|
||||
};
|
||||
}
|
||||
if (!syntax) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
reason:
|
||||
"Wait for the ECMAScript syntax provider to finish parsing the current pattern.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
status: "available",
|
||||
preview: formatEcmaScriptPattern(pattern, syntax),
|
||||
};
|
||||
} catch (cause) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
reason:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "The current syntax snapshot cannot be formatted.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds: number): string {
|
||||
if (milliseconds < 1) return "<1 ms";
|
||||
if (milliseconds < 1_000) return `${milliseconds.toFixed(0)} ms`;
|
||||
return `${(milliseconds / 1_000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
function observationIdentity(check: PatternFormatSnapshotCheck): string {
|
||||
const before = check.before.engineIdentity ?? check.before.status;
|
||||
const after = check.after.engineIdentity ?? check.after.status;
|
||||
return before === after ? before : `${before} → ${after}`;
|
||||
}
|
||||
|
||||
function ValidationCheck({
|
||||
title,
|
||||
check,
|
||||
}: {
|
||||
readonly title: string;
|
||||
readonly check: PatternFormatSnapshotCheck;
|
||||
}) {
|
||||
return (
|
||||
<article className={`formatter-check is-${check.comparison}`}>
|
||||
<header>
|
||||
<strong>{title}</strong>
|
||||
<span>{check.comparison}</span>
|
||||
</header>
|
||||
<p>{check.summary}</p>
|
||||
<small>{observationIdentity(check)}</small>
|
||||
{check.mismatchKinds.length > 0 ? (
|
||||
<code>{check.mismatchKinds.join(" · ")}</code>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternFormatterPanel({
|
||||
flavour,
|
||||
flavourVersion,
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
syntax,
|
||||
subject,
|
||||
replacement,
|
||||
scanAll,
|
||||
timeoutMs,
|
||||
tests,
|
||||
onApply,
|
||||
onClose,
|
||||
createValidator = () => new PatternFormatValidator(),
|
||||
}: PatternFormatterPanelProps) {
|
||||
const preview = useMemo(
|
||||
() => previewState(flavour, pattern, syntax),
|
||||
[flavour, pattern, syntax],
|
||||
);
|
||||
const configuration = useMemo<ValidationConfiguration | undefined>(
|
||||
() =>
|
||||
preview.status === "available"
|
||||
? {
|
||||
flavourVersion,
|
||||
preview: preview.preview,
|
||||
flags,
|
||||
options,
|
||||
subject,
|
||||
replacement,
|
||||
scanAll,
|
||||
timeoutMs,
|
||||
tests,
|
||||
}
|
||||
: undefined,
|
||||
[
|
||||
flags,
|
||||
flavourVersion,
|
||||
options,
|
||||
preview,
|
||||
replacement,
|
||||
scanAll,
|
||||
subject,
|
||||
tests,
|
||||
timeoutMs,
|
||||
],
|
||||
);
|
||||
const validator = useRef<PatternFormatValidatorClient | undefined>(undefined);
|
||||
const revision = useRef(0);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState({ completed: 0, total: 0 });
|
||||
const [message, setMessage] = useState(
|
||||
"Review the exact preview before running bounded validation.",
|
||||
);
|
||||
const [error, setError] = useState<string>();
|
||||
const [record, setRecord] = useState<ValidationRecord>();
|
||||
const [confirmation, setConfirmation] = useState<ConfirmationRecord>();
|
||||
|
||||
const currentRecord =
|
||||
record?.configuration === configuration ? record : undefined;
|
||||
const confirmed =
|
||||
confirmation !== undefined &&
|
||||
confirmation.configuration === configuration &&
|
||||
confirmation.checked;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
revision.current += 1;
|
||||
validator.current?.dispose();
|
||||
validator.current = undefined;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancel = () => {
|
||||
revision.current += 1;
|
||||
validator.current?.cancel();
|
||||
setRunning(false);
|
||||
setMessage("Validation cancelled; formatter workers were terminated.");
|
||||
};
|
||||
|
||||
const runValidation = async () => {
|
||||
if (
|
||||
!configuration ||
|
||||
!configuration.preview.changed ||
|
||||
!configuration.preview.withinPatternLimit
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentValidator =
|
||||
validator.current ?? (validator.current = createValidator());
|
||||
currentValidator.cancel();
|
||||
revision.current += 1;
|
||||
const runRevision = revision.current;
|
||||
setRunning(true);
|
||||
setConfirmation(undefined);
|
||||
setError(undefined);
|
||||
setRecord(undefined);
|
||||
setProgress({ completed: 0, total: 0 });
|
||||
setMessage(
|
||||
"Reparsing, compiling and comparing exact source/candidate snapshots…",
|
||||
);
|
||||
try {
|
||||
const result = await currentValidator.validate(
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
...(configuration.flavourVersion === undefined
|
||||
? {}
|
||||
: { flavourVersion: configuration.flavourVersion }),
|
||||
sourcePattern: configuration.preview.sourcePattern,
|
||||
formattedPattern: configuration.preview.formattedPattern,
|
||||
flags: configuration.flags,
|
||||
options: configuration.options,
|
||||
subject: configuration.subject,
|
||||
replacement: configuration.replacement,
|
||||
scanAll: configuration.scanAll,
|
||||
timeoutMs: configuration.timeoutMs,
|
||||
tests: configuration.tests,
|
||||
},
|
||||
(completed, total) => {
|
||||
if (revision.current !== runRevision) return;
|
||||
setProgress({ completed, total });
|
||||
setMessage(
|
||||
total === 0
|
||||
? "The current subject/replacement snapshot is being compared."
|
||||
: `Validated ${completed.toLocaleString()} of ${total.toLocaleString()} applicable unit tests.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (revision.current !== runRevision) return;
|
||||
setRecord({ configuration, result });
|
||||
setMessage(
|
||||
result.status === "equivalent"
|
||||
? "All completed safety gates retained the same observable behavior."
|
||||
: result.status === "different"
|
||||
? "Formatting changed at least one checked observation."
|
||||
: "Validation was inconclusive; the candidate cannot be applied.",
|
||||
);
|
||||
} catch (cause) {
|
||||
if (revision.current !== runRevision) return;
|
||||
const cancelled =
|
||||
cause instanceof WorkerRequestError && cause.kind === "cancelled";
|
||||
setError(
|
||||
cancelled
|
||||
? "Validation was cancelled."
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "Formatter validation failed.",
|
||||
);
|
||||
setMessage(
|
||||
cancelled
|
||||
? "Validation cancelled; no candidate can be applied."
|
||||
: "Validation failed; no candidate can be applied.",
|
||||
);
|
||||
} finally {
|
||||
if (revision.current === runRevision) setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (running) cancel();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const result = currentRecord?.result;
|
||||
const canApply =
|
||||
result?.canApply === true &&
|
||||
confirmed &&
|
||||
configuration?.preview.changed === true;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="panel formatter-panel"
|
||||
aria-labelledby="formatter-heading"
|
||||
>
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
Grammar-backed preview + bounded proof gates
|
||||
</p>
|
||||
<h2 id="formatter-heading">Format pattern</h2>
|
||||
</div>
|
||||
<div className="panel-heading-actions">
|
||||
<span className="provenance-badge">ECMAScript only</span>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close pattern formatter"
|
||||
data-dialog-initial-focus
|
||||
onClick={close}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{preview.status === "unavailable" ? (
|
||||
<p className="formatter-unavailable" role="status">
|
||||
{preview.reason}
|
||||
</p>
|
||||
) : (
|
||||
<div className="formatter-body">
|
||||
<p className="formatter-advisory">
|
||||
This deliberately narrow formatter escapes literal slashes and raw
|
||||
control/line-separator characters. It does not insert insignificant
|
||||
whitespace—ECMAScript patterns generally have no such whitespace.
|
||||
</p>
|
||||
|
||||
<section className="formatter-preview" aria-label="Format preview">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
{preview.preview.formatter.id} · v
|
||||
{preview.preview.formatter.version}
|
||||
</p>
|
||||
<h3>Exact preview</h3>
|
||||
</div>
|
||||
<span>
|
||||
{preview.preview.totalChanges.toLocaleString()} transformation
|
||||
{preview.preview.totalChanges === 1 ? "" : "s"}
|
||||
</span>
|
||||
</header>
|
||||
<div className="formatter-pattern-grid">
|
||||
<article>
|
||||
<strong>Source</strong>
|
||||
<pre>
|
||||
<code>{preview.preview.sourcePattern}</code>
|
||||
</pre>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Formatted candidate</strong>
|
||||
<pre data-testid="formatted-pattern-preview">
|
||||
<code>{preview.preview.formattedPattern}</code>
|
||||
</pre>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{preview.preview.changed ? (
|
||||
<div className="formatter-table-scroll">
|
||||
<table aria-label="Formatter transformations">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">UTF-16 range</th>
|
||||
<th scope="col">Kind</th>
|
||||
<th scope="col">Before</th>
|
||||
<th scope="col">After</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.preview.changes
|
||||
.slice(0, MAXIMUM_VISIBLE_CHANGES)
|
||||
.map((change) => (
|
||||
<tr
|
||||
key={`${change.startUtf16}:${change.endUtf16}:${change.kind}`}
|
||||
title={change.explanation}
|
||||
>
|
||||
<td>
|
||||
{change.startUtf16}–{change.endUtf16}
|
||||
</td>
|
||||
<td>{change.kind.replaceAll("-", " ")}</td>
|
||||
<td>
|
||||
<code>{JSON.stringify(change.before)}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{JSON.stringify(change.after)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{preview.preview.totalChanges > MAXIMUM_VISIBLE_CHANGES ? (
|
||||
<p>
|
||||
The table shows the first{" "}
|
||||
{MAXIMUM_VISIBLE_CHANGES.toLocaleString()} transformations.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="formatter-empty">
|
||||
This pattern already uses the formatter’s canonical literal and
|
||||
control escapes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="formatter-notices">
|
||||
{preview.preview.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{preview.preview.changed ? (
|
||||
<section className="formatter-validation">
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">Required before apply</p>
|
||||
<h3>Safety validation</h3>
|
||||
</div>
|
||||
<span>
|
||||
{result?.status ?? (running ? "running" : "not run")}
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
className={`formatter-status is-${result?.status ?? (error ? "different" : "idle")}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<strong>{message}</strong>
|
||||
{error ? <p>{error}</p> : null}
|
||||
</div>
|
||||
{running ? (
|
||||
<div className="formatter-progress">
|
||||
<progress
|
||||
max={Math.max(1, progress.total)}
|
||||
value={Math.min(progress.completed, progress.total)}
|
||||
/>
|
||||
<span>
|
||||
{progress.completed.toLocaleString()} /{" "}
|
||||
{progress.total.toLocaleString()} tests
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="formatter-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={
|
||||
running ||
|
||||
!preview.preview.withinPatternLimit ||
|
||||
!preview.preview.changed
|
||||
}
|
||||
onClick={() => void runValidation()}
|
||||
>
|
||||
Validate exact snapshots
|
||||
</button>
|
||||
{running ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={cancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="formatter-result">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Syntax</dt>
|
||||
<dd>
|
||||
{result.sourceSyntaxAccepted &&
|
||||
result.formattedSyntaxAccepted
|
||||
? "both accepted"
|
||||
: "rejected"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Capture shape</dt>
|
||||
<dd>
|
||||
{result.captureShapePreserved ? "preserved" : "changed"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unit tests</dt>
|
||||
<dd>
|
||||
{result.completedTestCount.toLocaleString()} /{" "}
|
||||
{result.applicableTestCount.toLocaleString()} applicable
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unrelated / disabled</dt>
|
||||
<dd>{result.skippedTestCount.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Elapsed</dt>
|
||||
<dd>{formatDuration(result.elapsedMs)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="formatter-checks">
|
||||
<ValidationCheck
|
||||
title="Current subject and replacement"
|
||||
check={result.current}
|
||||
/>
|
||||
{result.tests
|
||||
.slice(0, MAXIMUM_VISIBLE_CHECKS)
|
||||
.map((test) => (
|
||||
<ValidationCheck
|
||||
key={test.testId}
|
||||
title={test.name}
|
||||
check={test}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{result.tests.length > MAXIMUM_VISIBLE_CHECKS ? (
|
||||
<p className="formatter-result-note">
|
||||
The panel shows the first{" "}
|
||||
{MAXIMUM_VISIBLE_CHECKS.toLocaleString()} completed test
|
||||
comparisons.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{result.differences.length > 0 ? (
|
||||
<section
|
||||
className="formatter-differences"
|
||||
aria-labelledby="formatter-differences-heading"
|
||||
>
|
||||
<h4 id="formatter-differences-heading">
|
||||
Differences and inconclusive gates
|
||||
</h4>
|
||||
<ul>
|
||||
{result.differences
|
||||
.slice(0, MAXIMUM_VISIBLE_CHECKS)
|
||||
.map((difference, index) => (
|
||||
<li
|
||||
key={`${difference.code}:${difference.testId ?? ""}:${index}`}
|
||||
>
|
||||
<strong>{difference.code}</strong>{" "}
|
||||
{difference.summary}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
<ul className="formatter-notices">
|
||||
{result.notices.map((notice) => (
|
||||
<li key={notice}>{notice}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<label className="check-control formatter-confirmation">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
disabled={!result.canApply}
|
||||
onChange={(event) => {
|
||||
if (!configuration) return;
|
||||
setConfirmation({
|
||||
configuration,
|
||||
checked: event.target.checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
I understand that this bounded validation is not a proof
|
||||
for every possible subject, and I want to apply the exact
|
||||
candidate shown above.
|
||||
</span>
|
||||
</label>
|
||||
<div className="formatter-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={!canApply}
|
||||
onClick={() => {
|
||||
if (!canApply || !configuration) return;
|
||||
onApply(configuration.preview.formattedPattern);
|
||||
}}
|
||||
>
|
||||
Apply formatted pattern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
59
src/components/Pcre2TracePanel.test.tsx
Normal file
59
src/components/Pcre2TracePanel.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Pcre2TracePanel } from "./Pcre2TracePanel";
|
||||
|
||||
function renderPanel(flavour: "ecmascript" | "pcre2") {
|
||||
return render(
|
||||
<Pcre2TracePanel
|
||||
active
|
||||
flavour={flavour}
|
||||
pattern="(a+)+$"
|
||||
flags={["g"]}
|
||||
options={
|
||||
flavour === "pcre2"
|
||||
? {
|
||||
matchLimit: 1_000_000,
|
||||
depthLimit: 1_000,
|
||||
heapLimitKib: 32_768,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
subject="aaaa!"
|
||||
timeoutMs={2_000}
|
||||
onClose={vi.fn()}
|
||||
onSelectRanges={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Pcre2TracePanel", () => {
|
||||
it("discloses reported versus derived trace semantics and hard caps", () => {
|
||||
renderPanel("pcre2");
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Execution trace" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/actual stream reported by PCRE2_AUTO_CALLOUT/u),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Movement labels are derived only/u),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum events" }),
|
||||
).toHaveAttribute("max", "50000");
|
||||
expect(
|
||||
screen.getByRole("spinbutton", {
|
||||
name: "Maximum serialized bytes",
|
||||
}),
|
||||
).toHaveAttribute("max", String(10 * 1024 * 1024));
|
||||
expect(screen.getByRole("button", { name: "Run trace" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("does not offer an invented ECMAScript trace", () => {
|
||||
renderPanel("ecmascript");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Run trace" })).toBeDisabled();
|
||||
expect(screen.getByText(/Select PCRE2 to enable/u)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
407
src/components/Pcre2TracePanel.tsx
Normal file
407
src/components/Pcre2TracePanel.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Pcre2TraceSupervisor } from "../regex/execution/Pcre2TraceSupervisor";
|
||||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||||
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
|
||||
import type {
|
||||
RegexEngineOptions,
|
||||
RegexFlavourId,
|
||||
} from "../regex/model/flavour";
|
||||
import type { Pcre2TraceEvent, Pcre2TraceResult } from "../regex/model/trace";
|
||||
import type { SourceRange } from "../regex/model/syntax";
|
||||
|
||||
const DEFAULT_TRACE_EVENTS = 5_000;
|
||||
const DEFAULT_TRACE_BYTES = 2 * 1024 * 1024;
|
||||
const MAXIMUM_RENDERED_TRACE_EVENTS = 2_000;
|
||||
|
||||
type TraceState =
|
||||
| { readonly status: "idle"; readonly message: string }
|
||||
| { readonly status: "running"; readonly message: string }
|
||||
| { readonly status: "ready"; readonly message: string }
|
||||
| { readonly status: "timeout"; readonly message: string }
|
||||
| { readonly status: "error"; readonly message: string };
|
||||
|
||||
function movementLabel(event: Pcre2TraceEvent): string {
|
||||
switch (event.movement.classification) {
|
||||
case "first-event":
|
||||
return "first";
|
||||
case "same-position":
|
||||
return "same position";
|
||||
case "apparent-backtrack":
|
||||
return "apparent backtrack";
|
||||
case "forward":
|
||||
return "forward";
|
||||
}
|
||||
}
|
||||
|
||||
export function Pcre2TracePanel({
|
||||
active,
|
||||
flavour,
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
subject,
|
||||
timeoutMs,
|
||||
onClose,
|
||||
onSelectRanges,
|
||||
}: {
|
||||
readonly active: boolean;
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly subject: string;
|
||||
readonly timeoutMs: number;
|
||||
readonly onClose: () => void;
|
||||
readonly onSelectRanges: (
|
||||
patternRange: SourceRange,
|
||||
subjectRange: SourceRange,
|
||||
) => void;
|
||||
}) {
|
||||
const supervisor = useRef<Pcre2TraceSupervisor | null>(null);
|
||||
const requestRevision = useRef(0);
|
||||
const [maximumEvents, setMaximumEvents] = useState(DEFAULT_TRACE_EVENTS);
|
||||
const [maximumBytes, setMaximumBytes] = useState(DEFAULT_TRACE_BYTES);
|
||||
const [completed, setCompleted] = useState<{
|
||||
readonly result: Pcre2TraceResult;
|
||||
readonly input: {
|
||||
readonly pattern: string;
|
||||
readonly subject: string;
|
||||
readonly flags: string;
|
||||
readonly options: string;
|
||||
};
|
||||
}>();
|
||||
const [state, setState] = useState<TraceState>({
|
||||
status: "idle",
|
||||
message: "Run an isolated PCRE2 automatic-callout trace.",
|
||||
});
|
||||
|
||||
const traceEngine = () => {
|
||||
supervisor.current ??= new Pcre2TraceSupervisor();
|
||||
return supervisor.current;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => supervisor.current?.dispose();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
supervisor.current?.cancel();
|
||||
}, [active]);
|
||||
|
||||
const limitsValid =
|
||||
Number.isSafeInteger(maximumEvents) &&
|
||||
maximumEvents >= 1 &&
|
||||
maximumEvents <= DEFAULT_REGEX_LIMITS.maximumTraceEvents &&
|
||||
Number.isSafeInteger(maximumBytes) &&
|
||||
maximumBytes >= 32 &&
|
||||
maximumBytes <= DEFAULT_REGEX_LIMITS.maximumTraceBytes;
|
||||
|
||||
const runTrace = async () => {
|
||||
if (flavour !== "pcre2" || !limitsValid) return;
|
||||
const revision = (requestRevision.current += 1);
|
||||
setCompleted(undefined);
|
||||
setState({
|
||||
status: "running",
|
||||
message: "Collecting reported callouts in a dedicated worker…",
|
||||
});
|
||||
try {
|
||||
const next = await traceEngine().trace(
|
||||
{
|
||||
flavour,
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
subject,
|
||||
maximumTraceEvents: maximumEvents,
|
||||
maximumTraceBytes: maximumBytes,
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
if (revision !== requestRevision.current) return;
|
||||
setCompleted({
|
||||
result: next,
|
||||
input: {
|
||||
pattern,
|
||||
subject,
|
||||
flags: flags.join(""),
|
||||
options: JSON.stringify(options),
|
||||
},
|
||||
});
|
||||
const error = next.diagnostics.find(
|
||||
(diagnostic) => diagnostic.severity === "error",
|
||||
);
|
||||
setState(
|
||||
next.accepted
|
||||
? {
|
||||
status: "ready",
|
||||
message: next.truncated
|
||||
? "Bounded trace complete; collection stopped at a configured cap."
|
||||
: "Bounded trace complete.",
|
||||
}
|
||||
: {
|
||||
status: "error",
|
||||
message: error?.message ?? "PCRE2 rejected the trace request.",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (revision !== requestRevision.current) return;
|
||||
if (error instanceof WorkerRequestError && error.kind === "timeout") {
|
||||
setState({
|
||||
status: "timeout",
|
||||
message: `${error.message}; the trace worker was terminated.`,
|
||||
});
|
||||
} else if (
|
||||
error instanceof WorkerRequestError &&
|
||||
error.kind === "cancelled"
|
||||
) {
|
||||
setState({
|
||||
status: "idle",
|
||||
message: "Trace cancelled; its worker was terminated.",
|
||||
});
|
||||
} else {
|
||||
setState({
|
||||
status: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The PCRE2 trace worker failed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resultIsCurrent =
|
||||
completed?.input.pattern === pattern &&
|
||||
completed.input.subject === subject &&
|
||||
completed.input.flags === flags.join("") &&
|
||||
completed.input.options === JSON.stringify(options);
|
||||
const visibleResult = resultIsCurrent ? completed.result : undefined;
|
||||
const visibleState =
|
||||
resultIsCurrent || state.status === "running"
|
||||
? state
|
||||
: {
|
||||
status: "idle" as const,
|
||||
message: "Inputs changed; run a new isolated trace.",
|
||||
};
|
||||
const renderedEvents =
|
||||
visibleResult?.events.slice(0, MAXIMUM_RENDERED_TRACE_EVENTS) ?? [];
|
||||
return (
|
||||
<section className="panel trace-panel" aria-labelledby="trace-heading">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">PCRE2 10.47 · reported automatic callouts</p>
|
||||
<h2 id="trace-heading">Execution trace</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close execution trace"
|
||||
data-dialog-initial-focus
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<p className="trace-disclosure">
|
||||
This is an actual stream reported by PCRE2_AUTO_CALLOUT for one{" "}
|
||||
<code>pcre2_match()</code> invocation. Movement labels are derived only
|
||||
from adjacent reported subject positions; the stream is not a complete
|
||||
record of every internal engine action.
|
||||
</p>
|
||||
<div className="trace-controls">
|
||||
<label>
|
||||
<span>Maximum events</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumTraceEvents}
|
||||
step={1}
|
||||
value={maximumEvents}
|
||||
onChange={(event) => setMaximumEvents(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Maximum serialized bytes</span>
|
||||
<input
|
||||
type="number"
|
||||
min={32}
|
||||
max={DEFAULT_REGEX_LIMITS.maximumTraceBytes}
|
||||
step={1024}
|
||||
value={maximumBytes}
|
||||
onChange={(event) => setMaximumBytes(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={
|
||||
flavour !== "pcre2" ||
|
||||
!limitsValid ||
|
||||
visibleState.status === "running"
|
||||
}
|
||||
onClick={() => void runTrace()}
|
||||
>
|
||||
{visibleState.status === "running" ? "Tracing…" : "Run trace"}
|
||||
</button>
|
||||
{visibleState.status === "running" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
requestRevision.current += 1;
|
||||
supervisor.current?.cancel();
|
||||
setState({
|
||||
status: "idle",
|
||||
message: "Trace cancelled; its worker was terminated.",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel trace
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{flavour !== "pcre2" ? (
|
||||
<p className="empty-state" role="status">
|
||||
Select PCRE2 to enable its automatic-callout trace.
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={`trace-status status-${visibleState.status}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<strong>{visibleState.message}</strong>
|
||||
{visibleResult?.accepted ? (
|
||||
<small>
|
||||
{visibleResult.events.length.toLocaleString()} complete retained
|
||||
events of {visibleResult.totalEventCount.toLocaleString()} callback
|
||||
event(s) observed before return ·{" "}
|
||||
{visibleResult.traceBytes.toLocaleString()} serialized bytes · last
|
||||
complete event{" "}
|
||||
{visibleResult.lastCompleteEvent?.toLocaleString() ?? "none"} ·{" "}
|
||||
native status {visibleResult.nativeMatchStatus} ·{" "}
|
||||
{visibleResult.matched ? "matched" : "not matched or bounded stop"}{" "}
|
||||
· {visibleResult.elapsedMs.toFixed(2)} ms
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
{visibleResult?.diagnostics.length ? (
|
||||
<ul className="trace-diagnostics">
|
||||
{visibleResult.diagnostics.map((diagnostic) => (
|
||||
<li
|
||||
key={diagnostic.id}
|
||||
className={`diagnostic-${diagnostic.severity}`}
|
||||
>
|
||||
<strong>{diagnostic.severity}</strong> {diagnostic.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{visibleResult && visibleResult.events.length > renderedEvents.length ? (
|
||||
<p className="render-limit-banner" role="status">
|
||||
<strong>Trace viewer limited.</strong> Showing the first{" "}
|
||||
{renderedEvents.length.toLocaleString()} of{" "}
|
||||
{visibleResult.events.length.toLocaleString()} complete retained
|
||||
events. The worker result remains bounded by both configured caps.
|
||||
</p>
|
||||
) : null}
|
||||
{renderedEvents.length > 0 ? (
|
||||
<div className="trace-table-scroll">
|
||||
<table className="trace-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Event</th>
|
||||
<th scope="col">Callout</th>
|
||||
<th scope="col">Pattern</th>
|
||||
<th scope="col">Next item</th>
|
||||
<th scope="col">Subject</th>
|
||||
<th scope="col">Captures</th>
|
||||
<th scope="col">Mark</th>
|
||||
<th scope="col">Movement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderedEvents.map((event) => (
|
||||
<tr key={event.eventNumber}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="trace-event-select"
|
||||
aria-label={`Select trace event ${event.eventNumber}`}
|
||||
onClick={() =>
|
||||
onSelectRanges(
|
||||
{
|
||||
startUtf16:
|
||||
event.reported.nextPatternItem.startUtf16,
|
||||
endUtf16: event.reported.nextPatternItem.endUtf16,
|
||||
},
|
||||
{
|
||||
startUtf16: event.reported.subjectPosition.utf16,
|
||||
endUtf16: event.reported.subjectPosition.utf16,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{event.eventNumber}
|
||||
</button>
|
||||
</td>
|
||||
<td>{event.reported.calloutNumber}</td>
|
||||
<td>
|
||||
{event.reported.patternPosition.utf16} UTF-16
|
||||
<small>
|
||||
{event.reported.patternPosition.nativeByte} byte
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<code>
|
||||
{pattern.slice(
|
||||
event.reported.nextPatternItem.startUtf16,
|
||||
event.reported.nextPatternItem.endUtf16,
|
||||
) || "end"}
|
||||
</code>
|
||||
<small>
|
||||
{event.reported.nextPatternItem.startNativeByte}…
|
||||
{event.reported.nextPatternItem.endNativeByte} bytes
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{event.reported.subjectPosition.utf16} UTF-16
|
||||
<small>
|
||||
{event.reported.subjectPosition.nativeByte} byte
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
top {event.reported.captureTop} · last{" "}
|
||||
{event.reported.captureLast}
|
||||
</td>
|
||||
<td>
|
||||
{event.reported.mark ? (
|
||||
<code>{event.reported.mark}</code>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`trace-movement trace-${event.movement.classification}`}
|
||||
title={event.movement.explanation}
|
||||
>
|
||||
{movementLabel(event)}
|
||||
</span>
|
||||
<small>
|
||||
{event.movement.subjectDeltaBytes > 0 ? "+" : ""}
|
||||
{event.movement.subjectDeltaBytes} bytes · derived
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : visibleResult?.accepted ? (
|
||||
<p className="empty-state">No callout event was retained.</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,10 @@ export function ProjectPanel({
|
||||
<p>
|
||||
<strong>{project.name}</strong> · schema {project.schemaVersion}
|
||||
</p>
|
||||
<p className="field-help">
|
||||
Corpus documents, applied outputs and corpus results are always excluded
|
||||
from project JSON and local saves.
|
||||
</p>
|
||||
<label className="check-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -5,9 +5,9 @@ import { QuickReference } from "./QuickReference";
|
||||
|
||||
describe("QuickReference", () => {
|
||||
it("shows flavour, flags, example and authoritative source before insertion", async () => {
|
||||
const onInsert = vi.fn();
|
||||
const onUsePattern = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<QuickReference onInsert={onInsert} />);
|
||||
render(<QuickReference onUsePattern={onUsePattern} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText("Filter constructs"),
|
||||
@@ -20,7 +20,37 @@ describe("QuickReference", () => {
|
||||
screen.getByRole("link", { name: "ECMAScript Atom grammar" }),
|
||||
).toHaveAttribute("href", expect.stringContaining("tc39.es"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Insert example" }));
|
||||
expect(onInsert).toHaveBeenCalledWith("a.b");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Use example as pattern" }),
|
||||
);
|
||||
expect(onUsePattern).toHaveBeenCalledWith("a.b");
|
||||
});
|
||||
|
||||
it("shows an explicit empty state for a filter without matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickReference onUsePattern={vi.fn()} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText("Filter constructs"),
|
||||
"no such construct",
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"No reference constructs match",
|
||||
);
|
||||
expect(screen.getByText("0 of 12 constructs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the authoritative PCRE2 construct set", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuickReference flavour="pcre2" onUsePattern={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Original PCRE2 reference")).toBeInTheDocument();
|
||||
expect(screen.getByText("8 of 8 constructs")).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Filter constructs"), "branch reset");
|
||||
expect(screen.getByText("Branch-reset group")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "PCRE2 pattern documentation" }),
|
||||
).toHaveAttribute("href", expect.stringContaining("pcre.org"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ECMASCRIPT_REFERENCE } from "../regex/reference/token-reference";
|
||||
import type { RegexFlavourId } from "../regex/model/flavour";
|
||||
import {
|
||||
ECMASCRIPT_REFERENCE,
|
||||
PCRE2_REFERENCE,
|
||||
} from "../regex/reference/token-reference";
|
||||
|
||||
export function QuickReference({
|
||||
onInsert,
|
||||
onUsePattern,
|
||||
onClose,
|
||||
flavour = "ecmascript",
|
||||
}: {
|
||||
readonly onInsert: (syntax: string) => void;
|
||||
readonly onUsePattern: (syntax: string) => void;
|
||||
readonly onClose?: () => void;
|
||||
readonly flavour?: RegexFlavourId;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const reference =
|
||||
flavour === "pcre2" ? PCRE2_REFERENCE : ECMASCRIPT_REFERENCE;
|
||||
const entries = useMemo(() => {
|
||||
const needle = query.toLocaleLowerCase();
|
||||
const normalizeSearchText = (value: string) =>
|
||||
value
|
||||
.toLocaleLowerCase()
|
||||
.replaceAll(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim();
|
||||
const needle = normalizeSearchText(query);
|
||||
return needle
|
||||
? ECMASCRIPT_REFERENCE.filter((entry) =>
|
||||
? reference.filter((entry) =>
|
||||
normalizeSearchText(
|
||||
[
|
||||
entry.construct,
|
||||
entry.syntax,
|
||||
@@ -18,13 +34,11 @@ export function QuickReference({
|
||||
entry.example,
|
||||
entry.flags.join(" "),
|
||||
entry.source,
|
||||
]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle),
|
||||
].join(" "),
|
||||
).includes(needle),
|
||||
)
|
||||
: ECMASCRIPT_REFERENCE;
|
||||
}, [query]);
|
||||
: reference;
|
||||
}, [query, reference]);
|
||||
return (
|
||||
<section
|
||||
className="panel reference-panel"
|
||||
@@ -32,9 +46,27 @@ export function QuickReference({
|
||||
>
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Original ECMAScript reference</p>
|
||||
<p className="eyebrow">
|
||||
Original {flavour === "pcre2" ? "PCRE2" : "ECMAScript"} reference
|
||||
</p>
|
||||
<h2 id="reference-heading">Quick reference</h2>
|
||||
</div>
|
||||
<div className="panel-heading-actions">
|
||||
<span>
|
||||
{entries.length} of {reference.length} constructs
|
||||
</span>
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Close quick reference"
|
||||
data-dialog-initial-focus
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<label className="stacked-control">
|
||||
<span>Filter constructs</span>
|
||||
@@ -44,6 +76,7 @@ export function QuickReference({
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{entries.length > 0 ? (
|
||||
<ul className="reference-list">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
@@ -82,13 +115,18 @@ export function QuickReference({
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => onInsert(entry.example)}
|
||||
onClick={() => onUsePattern(entry.example)}
|
||||
>
|
||||
Insert example
|
||||
Use example as pattern
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="empty-state" role="status">
|
||||
No reference constructs match “{query.trim()}”.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ReplacementContributionPreview,
|
||||
ReplacementPreview,
|
||||
} from "../regex/replacement/replacement-preview";
|
||||
import type { RegexFlavourId } from "../regex/model/flavour";
|
||||
import {
|
||||
DEFAULT_REGEX_LIMITS,
|
||||
TEMPLATE_PRESENTATION_LIMITS,
|
||||
@@ -50,6 +51,7 @@ function ReplacementTokenRow({
|
||||
}
|
||||
|
||||
export function ReplacementPanel({
|
||||
flavour,
|
||||
replacement,
|
||||
onReplacementChange,
|
||||
syntax,
|
||||
@@ -61,6 +63,7 @@ export function ReplacementPanel({
|
||||
onSelectToken,
|
||||
onSelectContribution,
|
||||
}: {
|
||||
readonly flavour?: RegexFlavourId;
|
||||
readonly replacement: string;
|
||||
readonly onReplacementChange: (value: string) => void;
|
||||
readonly syntax?: ReplacementSyntaxResult;
|
||||
@@ -74,6 +77,9 @@ export function ReplacementPanel({
|
||||
contribution: ReplacementContributionPreview,
|
||||
) => void;
|
||||
}) {
|
||||
const activeFlavour =
|
||||
flavour ?? result?.execution.engine.flavour ?? "ecmascript";
|
||||
const engineLabel = activeFlavour === "pcre2" ? "PCRE2" : "ECMAScript";
|
||||
const retainedTokens = syntax?.tokens ?? [];
|
||||
const editorTokens = retainedTokens.slice(
|
||||
0,
|
||||
@@ -94,7 +100,7 @@ export function ReplacementPanel({
|
||||
<section className="panel editor-panel">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">ECMAScript template syntax</p>
|
||||
<p className="eyebrow">{engineLabel} template syntax</p>
|
||||
<h2>Replacement</h2>
|
||||
</div>
|
||||
<span className="provenance-badge">Templates only · no code</span>
|
||||
@@ -230,7 +236,7 @@ export function ReplacementPanel({
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
Native matches · bounded ECMAScript substitution
|
||||
Native matches · bounded {engineLabel} substitution
|
||||
</p>
|
||||
<h2>Replacement output</h2>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ function renderPanel(
|
||||
const props: UnitTestPanelProps = {
|
||||
tests: [],
|
||||
results: new Map<string, RegexTestResult>(),
|
||||
currentFlavour: "ecmascript",
|
||||
currentPattern: "current-pattern",
|
||||
currentFlags: ["u"],
|
||||
currentScanAll: false,
|
||||
@@ -489,6 +490,41 @@ describe("UnitTestPanel", () => {
|
||||
expect(screen.queryByText("Passing case")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows generated seed provenance and removes it from an edited draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
const generated: RegexTestCase = {
|
||||
id: "generated",
|
||||
name: "Generated positive",
|
||||
enabled: true,
|
||||
flavour: "ecmascript",
|
||||
pattern: "cat",
|
||||
flags: [],
|
||||
options: {},
|
||||
subject: "cat",
|
||||
expectation: { kind: "should-match" },
|
||||
generation: {
|
||||
kind: "generated",
|
||||
generatorId: "regex-tools-ast-cases",
|
||||
generatorVersion: "1",
|
||||
seed: "browser-seed-42",
|
||||
candidateId: "generated-aabbccdd-1",
|
||||
intendedOutcome: "match",
|
||||
},
|
||||
};
|
||||
const props = renderPanel({ tests: [generated] });
|
||||
|
||||
expect(screen.getByText(/generated · seed browser-seed-42/u)).toBeVisible();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Edit Generated positive" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Update test" }));
|
||||
|
||||
expect(props.onUpdate).toHaveBeenCalledWith(
|
||||
"generated",
|
||||
expect.not.objectContaining({ generation: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a bounded snapshot of the latest current result", async () => {
|
||||
const user = userEvent.setup();
|
||||
const currentResultDraft: RegexTestDraft = {
|
||||
|
||||
@@ -22,21 +22,17 @@ import {
|
||||
MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
|
||||
serializeRegexTestSuite,
|
||||
} from "../regex/tests/test-suite.serialization";
|
||||
import type { RegexFlavourId } from "../regex/model/flavour";
|
||||
import {
|
||||
AVAILABLE_REGEX_FLAVOURS,
|
||||
parseRegexFlags,
|
||||
type RegexFlavourDefinition,
|
||||
} from "../regex/flavours/flavour-registry";
|
||||
|
||||
const MAXIMUM_MATCH_COUNT = 1_000_000;
|
||||
const MAXIMUM_MATCH_INDEX = 1_000_000;
|
||||
const MAXIMUM_CAPTURE_NUMBER = 1_000;
|
||||
const MAXIMUM_TIMEOUT_MS = DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs;
|
||||
const FLAG_OPTIONS = [
|
||||
["g", "Global"],
|
||||
["i", "Ignore case"],
|
||||
["m", "Multiline"],
|
||||
["s", "Dot all"],
|
||||
["u", "Unicode"],
|
||||
["v", "Unicode sets"],
|
||||
["y", "Sticky"],
|
||||
["d", "Expose indices"],
|
||||
] as const;
|
||||
|
||||
type CaptureSelector = "number" | "name";
|
||||
type CaptureStatus = "participated" | "did-not-participate" | "matched-empty";
|
||||
@@ -52,11 +48,13 @@ export interface RegexTestDraft {
|
||||
readonly resourceLimits?: Readonly<
|
||||
Pick<RegexResourceLimits, "manualExecutionTimeoutMs">
|
||||
>;
|
||||
readonly generation?: RegexTestCase["generation"];
|
||||
}
|
||||
|
||||
export interface UnitTestPanelProps {
|
||||
readonly tests: readonly RegexTestCase[];
|
||||
readonly results: ReadonlyMap<string, RegexTestResult>;
|
||||
readonly currentFlavour: RegexFlavourId;
|
||||
readonly currentPattern: string;
|
||||
readonly currentFlags: readonly string[];
|
||||
readonly currentScanAll: boolean;
|
||||
@@ -104,7 +102,10 @@ function boundedText(value: string, label: string, maximum: number): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateDraft(draft: RegexTestDraft): RegexTestDraft {
|
||||
function validateDraft(
|
||||
draft: RegexTestDraft,
|
||||
flavour: RegexFlavourDefinition,
|
||||
): RegexTestDraft {
|
||||
boundedText(draft.name, "Test name", MAXIMUM_REGEX_TEST_NAME_UTF16);
|
||||
boundedText(draft.pattern, "Pattern", MAXIMUM_REGEX_TEST_PATTERN_UTF16);
|
||||
boundedText(draft.subject, "Subject", MAXIMUM_REGEX_TEST_SUBJECT_UTF16);
|
||||
@@ -114,19 +115,7 @@ function validateDraft(draft: RegexTestDraft): RegexTestDraft {
|
||||
`Subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
|
||||
);
|
||||
}
|
||||
if (new Set(draft.flags).size !== draft.flags.length) {
|
||||
throw new Error("Flags must not contain duplicates.");
|
||||
}
|
||||
if (
|
||||
draft.flags.some(
|
||||
(flag) => !FLAG_OPTIONS.some(([supported]) => supported === flag),
|
||||
)
|
||||
) {
|
||||
throw new Error("Flags contain an unsupported ECMAScript flag.");
|
||||
}
|
||||
if (draft.flags.includes("u") && draft.flags.includes("v")) {
|
||||
throw new Error("ECMAScript flags u and v cannot be combined.");
|
||||
}
|
||||
parseRegexFlags(draft.flags, flavour, "Flags");
|
||||
if (
|
||||
draft.replacement !== undefined &&
|
||||
draft.replacement.length >
|
||||
@@ -211,6 +200,7 @@ function downloadJson(serialized: string): void {
|
||||
export function UnitTestPanel({
|
||||
tests,
|
||||
results,
|
||||
currentFlavour,
|
||||
currentPattern,
|
||||
currentFlags,
|
||||
currentScanAll,
|
||||
@@ -228,6 +218,7 @@ export function UnitTestPanel({
|
||||
onCancel,
|
||||
running,
|
||||
}: UnitTestPanelProps) {
|
||||
const flavourDefinition = AVAILABLE_REGEX_FLAVOURS.require(currentFlavour);
|
||||
const helpId = useId();
|
||||
const errorId = useId();
|
||||
const formId = useId();
|
||||
@@ -357,7 +348,8 @@ export function UnitTestPanel({
|
||||
1,
|
||||
MAXIMUM_TIMEOUT_MS,
|
||||
);
|
||||
return validateDraft({
|
||||
return validateDraft(
|
||||
{
|
||||
name: name.trim() || "Untitled test",
|
||||
pattern,
|
||||
flags,
|
||||
@@ -374,7 +366,9 @@ export function UnitTestPanel({
|
||||
manualExecutionTimeoutMs: timeout,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
flavourDefinition,
|
||||
);
|
||||
}
|
||||
|
||||
function submitTest(): void {
|
||||
@@ -499,7 +493,7 @@ export function UnitTestPanel({
|
||||
function addCurrentResult(): void {
|
||||
if (running || maximumReached || !currentResultDraft) return;
|
||||
try {
|
||||
onAdd(validateDraft(currentResultDraft));
|
||||
onAdd(validateDraft(currentResultDraft, flavourDefinition));
|
||||
setFormError(undefined);
|
||||
} catch (error) {
|
||||
setFormError(error instanceof Error ? error.message : String(error));
|
||||
@@ -585,27 +579,36 @@ export function UnitTestPanel({
|
||||
</label>
|
||||
<fieldset className="test-flag-selector">
|
||||
<legend>Flags</legend>
|
||||
{FLAG_OPTIONS.map(([flag, label]) => (
|
||||
<label key={flag} title={label}>
|
||||
{flavourDefinition.flags.map((flag) => (
|
||||
<label key={flag.value} title={flag.label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${label} (${flag})`}
|
||||
checked={flags.includes(flag)}
|
||||
aria-label={`${flag.label} (${flag.value})`}
|
||||
checked={flags.includes(flag.value)}
|
||||
onChange={(event) => {
|
||||
setFlags((current) => {
|
||||
const next = new Set(current);
|
||||
if (event.target.checked) {
|
||||
next.add(flag);
|
||||
if (flag === "u") next.delete("v");
|
||||
if (flag === "v") next.delete("u");
|
||||
} else {
|
||||
next.delete(flag);
|
||||
next.add(flag.value);
|
||||
for (const group of flavourDefinition.mutuallyExclusiveFlags ??
|
||||
[]) {
|
||||
if (!group.includes(flag.value)) continue;
|
||||
for (const incompatible of group) {
|
||||
if (incompatible !== flag.value) {
|
||||
next.delete(incompatible);
|
||||
}
|
||||
return [...next];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.delete(flag.value);
|
||||
}
|
||||
return flavourDefinition.flags
|
||||
.map((definition) => definition.value)
|
||||
.filter((value) => next.has(value));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{flag}</span>
|
||||
<span>{flag.value}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
@@ -1041,6 +1044,9 @@ export function UnitTestPanel({
|
||||
/{test.pattern}/{test.flags.join("")} ·{" "}
|
||||
{expectationSummary(test.expectation)} ·{" "}
|
||||
{test.subject.length.toLocaleString()} UTF-16 units
|
||||
{test.generation
|
||||
? ` · generated · seed ${test.generation.seed}`
|
||||
: ""}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -240,6 +240,16 @@ describe("Workbench syntax coordination", () => {
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
|
||||
|
||||
await advanceParseDebounce();
|
||||
expect(syntaxHarness.patterns[0]?.request).toMatchObject({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "2025",
|
||||
flags: ["g", "u"],
|
||||
options: {},
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Flavour" }),
|
||||
).not.toBeDisabled();
|
||||
expect(screen.getByRole("combobox", { name: "Version" })).toBeDisabled();
|
||||
await resolvePattern(0, [
|
||||
{
|
||||
number: 1,
|
||||
@@ -308,6 +318,9 @@ describe("Workbench syntax coordination", () => {
|
||||
});
|
||||
expect(engineHarness.requests).toHaveLength(1);
|
||||
expect(engineHarness.requests[0]).toMatchObject({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
||||
options: {},
|
||||
pattern: "(?<new>b)",
|
||||
captureMetadata: [{ number: 1, name: "new" }],
|
||||
});
|
||||
@@ -380,4 +393,47 @@ describe("Workbench syntax coordination", () => {
|
||||
)?.cancelCalls,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opens the bounded subject minimizer with the current editor snapshot", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<Workbench />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Minimize" }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Minimize a reproducer" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Subject to minimize")).toHaveValue(
|
||||
"2026-07-24 alice\nInvalid row\n2025-12-31 Bérénice",
|
||||
);
|
||||
expect(screen.getByLabelText("Minimization failure kind")).toHaveValue(
|
||||
"unit-test-failure",
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Close subject minimizer" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Minimize a reproducer" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens and closes the grammar-backed formatter workspace", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<Workbench />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Format" }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Format pattern" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(/Wait for the ECMAScript syntax provider/),
|
||||
).toBeVisible();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Close pattern formatter" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Format pattern" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ import { EngineSupervisor } from "../regex/execution/EngineSupervisor";
|
||||
import { SyntaxSupervisor } from "../regex/execution/SyntaxSupervisor";
|
||||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||||
import type { RegexDiagnostic } from "../regex/model/diagnostics";
|
||||
import type {
|
||||
RegexEngineOptions,
|
||||
RegexFlavourId,
|
||||
} from "../regex/model/flavour";
|
||||
import type {
|
||||
ExtractionNode,
|
||||
RegexExecutionRequest,
|
||||
@@ -67,6 +71,20 @@ import { UnitTestPanel, type RegexTestDraft } from "./UnitTestPanel";
|
||||
import { QuickReference } from "./QuickReference";
|
||||
import { CapabilityPanel } from "./CapabilityPanel";
|
||||
import { ProjectPanel } from "./ProjectPanel";
|
||||
import { ModalDialog } from "./ModalDialog";
|
||||
import { CorpusPanel } from "./CorpusPanel";
|
||||
import { Pcre2TracePanel } from "./Pcre2TracePanel";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
import { ComparisonCodePanel } from "./ComparisonCodePanel";
|
||||
import { MinimizePanel } from "./MinimizePanel";
|
||||
import { GenerationPanel } from "./GenerationPanel";
|
||||
import { PatternFormatterPanel } from "./PatternFormatterPanel";
|
||||
import type { VerifiedGeneratedCase } from "../regex/generation/generation.types";
|
||||
import {
|
||||
AVAILABLE_REGEX_FLAVOURS,
|
||||
defaultRegexOptions,
|
||||
resolveRegexFlavourVersion,
|
||||
} from "../regex/flavours/flavour-registry";
|
||||
|
||||
const INITIAL_PATTERN =
|
||||
"(?<date>\\d{4}-\\d{2}-\\d{2})\\s+(?<user>[\\p{Letter}._-]+)";
|
||||
@@ -77,16 +95,7 @@ const INITIAL_REPLACEMENT = "$<user> — $<date>";
|
||||
const MAXIMUM_PATTERN_EDITOR_MARKS = 2_000;
|
||||
const MAXIMUM_SUBJECT_EDITOR_MARKS = 2_000;
|
||||
const MAXIMUM_TEST_SUITE_WALL_TIME_MS = 60_000;
|
||||
const FLAG_OPTIONS = [
|
||||
["g", "Global"],
|
||||
["i", "Ignore case"],
|
||||
["m", "Multiline"],
|
||||
["s", "Dot all"],
|
||||
["u", "Unicode"],
|
||||
["v", "Unicode sets"],
|
||||
["y", "Sticky"],
|
||||
["d", "Expose indices"],
|
||||
] as const;
|
||||
const INITIAL_FLAVOUR = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
|
||||
|
||||
type RunState =
|
||||
| { readonly status: "idle"; readonly message: string }
|
||||
@@ -314,16 +323,23 @@ function testCaseFromDraft(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
draft: RegexTestDraft,
|
||||
configuration: {
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly flavourVersion?: string;
|
||||
readonly options: RegexEngineOptions;
|
||||
},
|
||||
): RegexTestCase {
|
||||
return {
|
||||
id,
|
||||
name: draft.name,
|
||||
enabled,
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
||||
flavour: configuration.flavour,
|
||||
...(configuration.flavourVersion === undefined
|
||||
? {}
|
||||
: { flavourVersion: configuration.flavourVersion }),
|
||||
pattern: draft.pattern,
|
||||
flags: draft.flags,
|
||||
options: {},
|
||||
options: configuration.options,
|
||||
scanAll: draft.scanAll,
|
||||
subject: draft.subject,
|
||||
...(draft.replacement === undefined
|
||||
@@ -333,6 +349,7 @@ function testCaseFromDraft(
|
||||
...(draft.resourceLimits === undefined
|
||||
? {}
|
||||
: { resourceLimits: draft.resourceLimits }),
|
||||
...(draft.generation === undefined ? {} : { generation: draft.generation }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,8 +378,17 @@ function failedTestResult(testId: string, message: string): RegexTestResult {
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const [flavour, setFlavour] = useState<RegexFlavourId>(INITIAL_FLAVOUR.id);
|
||||
const [flavourVersion, setFlavourVersion] = useState(
|
||||
INITIAL_FLAVOUR.defaultVersion,
|
||||
);
|
||||
const [options, setOptions] = useState<RegexEngineOptions>(() =>
|
||||
defaultRegexOptions(INITIAL_FLAVOUR),
|
||||
);
|
||||
const [pattern, setPattern] = useState(INITIAL_PATTERN);
|
||||
const [flags, setFlags] = useState<readonly string[]>(["g", "u"]);
|
||||
const [flags, setFlags] = useState<readonly string[]>(
|
||||
INITIAL_FLAVOUR.defaultFlags,
|
||||
);
|
||||
const [subject, setSubject] = useState(INITIAL_SUBJECT);
|
||||
const [replacement, setReplacement] = useState(INITIAL_REPLACEMENT);
|
||||
const [listTemplate, setListTemplate] = useState("${user} — ${date}");
|
||||
@@ -406,8 +432,15 @@ export function Workbench() {
|
||||
const [testsRunning, setTestsRunning] = useState(false);
|
||||
const [showReference, setShowReference] = useState(false);
|
||||
const [showCapabilities, setShowCapabilities] = useState(false);
|
||||
const [showTrace, setShowTrace] = useState(false);
|
||||
const [showAnalysis, setShowAnalysis] = useState(false);
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
const [showMinimizer, setShowMinimizer] = useState(false);
|
||||
const [showGeneration, setShowGeneration] = useState(false);
|
||||
const [showFormatter, setShowFormatter] = useState(false);
|
||||
const [showProject, setShowProject] = useState(false);
|
||||
const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
|
||||
const [corpusSession, setCorpusSession] = useState(0);
|
||||
const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 });
|
||||
const [projectIdentity, setProjectIdentity] = useState(() => ({
|
||||
id: projectId(),
|
||||
@@ -423,6 +456,11 @@ export function Workbench() {
|
||||
const executionRevision = useRef(0);
|
||||
const testRunRevision = useRef(0);
|
||||
const liveExecutionTimer = useRef<number | undefined>(undefined);
|
||||
const flavourDefinition = AVAILABLE_REGEX_FLAVOURS.require(flavour);
|
||||
const activeTestConfiguration = useMemo(
|
||||
() => ({ flavour, flavourVersion, options }),
|
||||
[flavour, flavourVersion, options],
|
||||
);
|
||||
|
||||
const patternSyntaxEngine = () => {
|
||||
patternSyntaxSupervisor.current ??= new SyntaxSupervisor(
|
||||
@@ -528,7 +566,22 @@ export function Workbench() {
|
||||
[],
|
||||
);
|
||||
|
||||
const syntax = isPatternSyntaxForInput(syntaxSnapshot, pattern, flags)
|
||||
const syntaxRequest = useMemo(() => {
|
||||
const definition = AVAILABLE_REGEX_FLAVOURS.require(flavour);
|
||||
const version = resolveRegexFlavourVersion(
|
||||
flavourVersion,
|
||||
definition,
|
||||
"Selected flavour version",
|
||||
).definition;
|
||||
return {
|
||||
flavour,
|
||||
flavourVersion: version.syntaxVersion,
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
};
|
||||
}, [flags, flavour, flavourVersion, options, pattern]);
|
||||
const syntax = isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest)
|
||||
? syntaxSnapshot.result
|
||||
: undefined;
|
||||
|
||||
@@ -545,19 +598,12 @@ export function Workbench() {
|
||||
return;
|
||||
}
|
||||
void patternSyntaxEngine()
|
||||
.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "2025",
|
||||
pattern,
|
||||
flags,
|
||||
options: {},
|
||||
})
|
||||
.parsePattern(syntaxRequest)
|
||||
.then((result) => {
|
||||
if (syntaxRevision.current !== currentRevision) return;
|
||||
setSyntaxSnapshot({
|
||||
revision: currentRevision,
|
||||
pattern,
|
||||
flags,
|
||||
request: syntaxRequest,
|
||||
result,
|
||||
});
|
||||
setSelectedSyntax((selected) =>
|
||||
@@ -598,14 +644,14 @@ export function Workbench() {
|
||||
});
|
||||
}, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [clearExecutionResults, flags, pattern]);
|
||||
}, [clearExecutionResults, pattern, syntaxRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!syntax) return;
|
||||
const currentRevision = ++replacementSyntaxRevision.current;
|
||||
void replacementSyntaxEngine()
|
||||
.parseReplacement({
|
||||
flavour: "ecmascript",
|
||||
flavour,
|
||||
replacement,
|
||||
captureMetadata: syntax.captures,
|
||||
})
|
||||
@@ -622,22 +668,33 @@ export function Workbench() {
|
||||
console.warn("Replacement parsing failed", error);
|
||||
}
|
||||
});
|
||||
}, [replacement, syntax]);
|
||||
}, [flavour, replacement, syntax]);
|
||||
|
||||
const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]);
|
||||
|
||||
const makeRequest = useCallback(
|
||||
(): RegexExecutionRequest => ({
|
||||
flavour: "ecmascript",
|
||||
flavour,
|
||||
flavourVersion,
|
||||
pattern,
|
||||
flags,
|
||||
options,
|
||||
subject,
|
||||
captureMetadata: syntax?.captures ?? [],
|
||||
scanAll,
|
||||
maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||
maximumCaptureRows: DEFAULT_REGEX_LIMITS.maximumCaptureRows,
|
||||
}),
|
||||
[flags, pattern, scanAll, subject, syntax?.captures],
|
||||
[
|
||||
flags,
|
||||
flavour,
|
||||
flavourVersion,
|
||||
options,
|
||||
pattern,
|
||||
scanAll,
|
||||
subject,
|
||||
syntax?.captures,
|
||||
],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
@@ -653,8 +710,7 @@ export function Workbench() {
|
||||
!isPatternSyntaxCurrent(
|
||||
syntaxSnapshot,
|
||||
syntaxRevision.current,
|
||||
pattern,
|
||||
flags,
|
||||
syntaxRequest,
|
||||
)
|
||||
) {
|
||||
setRunState({
|
||||
@@ -744,6 +800,7 @@ export function Workbench() {
|
||||
"current-result-size-check",
|
||||
true,
|
||||
candidate,
|
||||
activeTestConfiguration,
|
||||
),
|
||||
]);
|
||||
return true;
|
||||
@@ -827,6 +884,7 @@ export function Workbench() {
|
||||
}
|
||||
},
|
||||
[
|
||||
activeTestConfiguration,
|
||||
clearExecutionResults,
|
||||
makeRequest,
|
||||
mode,
|
||||
@@ -838,6 +896,7 @@ export function Workbench() {
|
||||
syntax?.accepted,
|
||||
syntax?.captures.length,
|
||||
syntaxSnapshot,
|
||||
syntaxRequest,
|
||||
flags,
|
||||
pattern,
|
||||
tests,
|
||||
@@ -855,6 +914,7 @@ export function Workbench() {
|
||||
!liveWithinLimits ||
|
||||
!syntax?.accepted ||
|
||||
mode === "tests" ||
|
||||
mode === "corpus" ||
|
||||
(mode === "replace" && replacementSyntax?.accepted !== true)
|
||||
) {
|
||||
return;
|
||||
@@ -935,11 +995,11 @@ export function Workbench() {
|
||||
name: "Regex workbench",
|
||||
createdAt: projectIdentity.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
||||
flavour,
|
||||
flavourVersion,
|
||||
pattern,
|
||||
flags,
|
||||
options: {},
|
||||
options,
|
||||
replacement,
|
||||
mode,
|
||||
testText: { included: true, value: subject },
|
||||
@@ -1171,6 +1231,9 @@ export function Workbench() {
|
||||
}
|
||||
const parsed = await testSyntaxEngine().parsePattern({
|
||||
flavour: test.flavour,
|
||||
...(test.flavourVersion === undefined
|
||||
? {}
|
||||
: { flavourVersion: test.flavourVersion }),
|
||||
pattern: test.pattern,
|
||||
flags: test.flags,
|
||||
options: test.options,
|
||||
@@ -1250,14 +1313,55 @@ export function Workbench() {
|
||||
`A suite is limited to ${MAXIMUM_REGEX_TESTS.toLocaleString()} tests.`,
|
||||
);
|
||||
}
|
||||
const next = [...tests, testCaseFromDraft(projectId(), true, draft)];
|
||||
const next = [
|
||||
...tests,
|
||||
testCaseFromDraft(projectId(), true, draft, activeTestConfiguration),
|
||||
];
|
||||
assertRegexTestSuiteWithinLimit(next);
|
||||
setTests(next);
|
||||
};
|
||||
|
||||
const addGeneratedTests = (
|
||||
generatedCases: readonly VerifiedGeneratedCase[],
|
||||
) => {
|
||||
if (generatedCases.length === 0) return;
|
||||
if (tests.length + generatedCases.length > MAXIMUM_REGEX_TESTS) {
|
||||
throw new Error(
|
||||
`Adding ${generatedCases.length.toLocaleString()} generated cases would exceed the ${MAXIMUM_REGEX_TESTS.toLocaleString()} test limit.`,
|
||||
);
|
||||
}
|
||||
const appended = generatedCases.map((candidate) =>
|
||||
testCaseFromDraft(
|
||||
projectId(),
|
||||
true,
|
||||
{
|
||||
name: candidate.name.slice(0, MAXIMUM_REGEX_TEST_NAME_UTF16),
|
||||
pattern,
|
||||
flags,
|
||||
scanAll,
|
||||
subject: candidate.subject,
|
||||
expectation: { kind: candidate.expectation },
|
||||
generation: candidate.provenance,
|
||||
},
|
||||
activeTestConfiguration,
|
||||
),
|
||||
);
|
||||
const next = [...tests, ...appended];
|
||||
assertRegexTestSuiteWithinLimit(next);
|
||||
setTests(next);
|
||||
};
|
||||
|
||||
const updateTest = (id: string, draft: RegexTestDraft) => {
|
||||
const next = tests.map((test) =>
|
||||
test.id === id ? testCaseFromDraft(test.id, test.enabled, draft) : test,
|
||||
test.id === id
|
||||
? testCaseFromDraft(test.id, test.enabled, draft, {
|
||||
flavour: test.flavour,
|
||||
...(test.flavourVersion === undefined
|
||||
? {}
|
||||
: { flavourVersion: test.flavourVersion }),
|
||||
options: test.options,
|
||||
})
|
||||
: test,
|
||||
);
|
||||
assertRegexTestSuiteWithinLimit(next);
|
||||
setTests(next);
|
||||
@@ -1308,10 +1412,20 @@ export function Workbench() {
|
||||
};
|
||||
|
||||
const importProject = (project: RegexProjectV1) => {
|
||||
const importedFlavour = AVAILABLE_REGEX_FLAVOURS.require(project.flavour);
|
||||
invalidatePatternSyntax();
|
||||
executionRevision.current += 1;
|
||||
engineSupervisor.current?.cancel();
|
||||
cancelTestRun();
|
||||
setFlavour(importedFlavour.id);
|
||||
setFlavourVersion(
|
||||
resolveRegexFlavourVersion(
|
||||
project.flavourVersion,
|
||||
importedFlavour,
|
||||
"Project flavour version",
|
||||
).definition.value,
|
||||
);
|
||||
setOptions(project.options);
|
||||
setPattern(project.pattern);
|
||||
setProjectIdentity({ id: project.id, createdAt: project.createdAt });
|
||||
setFlags(project.flags);
|
||||
@@ -1322,6 +1436,7 @@ export function Workbench() {
|
||||
setListTemplate(project.listTemplate?.source ?? "$0");
|
||||
setMode(project.mode);
|
||||
setTests(project.tests);
|
||||
setCorpusSession((current) => current + 1);
|
||||
setCurrentResultDraft(undefined);
|
||||
setTestResults(new Map());
|
||||
setLive(false);
|
||||
@@ -1336,51 +1451,142 @@ export function Workbench() {
|
||||
});
|
||||
};
|
||||
|
||||
const changeFlavour = (value: string) => {
|
||||
const next = AVAILABLE_REGEX_FLAVOURS.parse(value, "Selected flavour");
|
||||
if (next.id === flavour) return;
|
||||
invalidateExecution();
|
||||
invalidatePatternSyntax();
|
||||
cancelTestRun();
|
||||
setFlavour(next.id);
|
||||
setFlavourVersion(next.defaultVersion);
|
||||
setOptions(defaultRegexOptions(next));
|
||||
setFlags(next.defaultFlags);
|
||||
};
|
||||
|
||||
const changeFlavourVersion = (value: string) => {
|
||||
if (
|
||||
value === flavourVersion ||
|
||||
!flavourDefinition.versions.some((version) => version.value === value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
invalidateExecution();
|
||||
invalidatePatternSyntax();
|
||||
setFlavourVersion(value);
|
||||
};
|
||||
|
||||
const changeEngineOption = (name: string, value: number) => {
|
||||
const definition = flavourDefinition.options.find(
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
if (
|
||||
!definition ||
|
||||
definition.kind !== "integer" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < definition.minimum ||
|
||||
value > definition.maximum ||
|
||||
options[name] === value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
invalidateExecution();
|
||||
invalidatePatternSyntax();
|
||||
setOptions((current) => ({ ...current, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="regex-application">
|
||||
<section className="command-bar" aria-label="Regex execution controls">
|
||||
<label>
|
||||
<span>Flavour</span>
|
||||
<select value="ecmascript" disabled>
|
||||
<option value="ecmascript">ECMAScript</option>
|
||||
<select
|
||||
value={flavour}
|
||||
disabled={AVAILABLE_REGEX_FLAVOURS.definitions.length < 2}
|
||||
onChange={(event) => changeFlavour(event.target.value)}
|
||||
>
|
||||
{AVAILABLE_REGEX_FLAVOURS.definitions.map((definition) => (
|
||||
<option key={definition.id} value={definition.id}>
|
||||
{definition.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Version</span>
|
||||
<select value="current" disabled>
|
||||
<option value="current">2025 syntax · current browser</option>
|
||||
<select
|
||||
value={flavourVersion}
|
||||
disabled={flavourDefinition.versions.length < 2}
|
||||
onChange={(event) => changeFlavourVersion(event.target.value)}
|
||||
>
|
||||
{flavourDefinition.versions.map((version) => (
|
||||
<option key={version.value} value={version.value}>
|
||||
{version.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="flag-selector">
|
||||
<legend>Flags</legend>
|
||||
{FLAG_OPTIONS.map(([flag, label]) => (
|
||||
<label key={flag} title={label}>
|
||||
{flavourDefinition.flags.map((flag) => (
|
||||
<label key={flag.value} title={flag.label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${label} (${flag})`}
|
||||
checked={flags.includes(flag)}
|
||||
aria-label={`${flag.label} (${flag.value})`}
|
||||
checked={flags.includes(flag.value)}
|
||||
onChange={(event) => {
|
||||
invalidateExecution();
|
||||
invalidatePatternSyntax();
|
||||
setFlags((current) => {
|
||||
const next = new Set(current);
|
||||
if (event.target.checked) {
|
||||
next.add(flag);
|
||||
if (flag === "u") next.delete("v");
|
||||
if (flag === "v") next.delete("u");
|
||||
} else {
|
||||
next.delete(flag);
|
||||
next.add(flag.value);
|
||||
for (const group of flavourDefinition.mutuallyExclusiveFlags ??
|
||||
[]) {
|
||||
if (!group.includes(flag.value)) continue;
|
||||
for (const incompatible of group) {
|
||||
if (incompatible !== flag.value) {
|
||||
next.delete(incompatible);
|
||||
}
|
||||
return FLAG_OPTIONS.map(([value]) => value).filter(
|
||||
(value) => next.has(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.delete(flag.value);
|
||||
}
|
||||
return flavourDefinition.flags
|
||||
.map((definition) => definition.value)
|
||||
.filter((value) => next.has(value));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{flag}</span>
|
||||
<span>{flag.value}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{flavourDefinition.options.length > 0 ? (
|
||||
<fieldset className="engine-option-selector">
|
||||
<legend>Engine limits</legend>
|
||||
{flavourDefinition.options.map((option) => (
|
||||
<label key={option.name}>
|
||||
<span>{option.label}</span>
|
||||
{option.kind === "integer" ? (
|
||||
<input
|
||||
type="number"
|
||||
value={Number(options[option.name])}
|
||||
min={option.minimum}
|
||||
max={option.maximum}
|
||||
step={1}
|
||||
onChange={(event) =>
|
||||
changeEngineOption(
|
||||
option.name,
|
||||
Number(event.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
<label className="check-control live-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1421,6 +1627,7 @@ export function Workbench() {
|
||||
runState.status === "running" ||
|
||||
testsRunning ||
|
||||
mode === "tests" ||
|
||||
mode === "corpus" ||
|
||||
!syntax?.accepted ||
|
||||
(mode === "replace" && replacementSyntax?.accepted !== true)
|
||||
}
|
||||
@@ -1451,8 +1658,9 @@ export function Workbench() {
|
||||
) : null}
|
||||
{scanAll && !flags.some((flag) => flag === "g" || flag === "y") ? (
|
||||
<p className="scan-banner">
|
||||
Scan all is explicit: the worker adds an internal <code>g</code> only
|
||||
for iteration. The pattern’s saved user flags remain unchanged.
|
||||
Scan all is explicit: the worker adds an internal global-iteration
|
||||
flag only for this request. The pattern’s saved user flags remain
|
||||
unchanged.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="workspace-toolbar">
|
||||
@@ -1462,6 +1670,7 @@ export function Workbench() {
|
||||
["match", "Match"],
|
||||
["replace", "Replace"],
|
||||
["list", "List"],
|
||||
["corpus", "Corpus / Apply"],
|
||||
["tests", "Unit tests"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
@@ -1481,11 +1690,77 @@ export function Workbench() {
|
||||
))}
|
||||
</nav>
|
||||
<div className="workspace-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setShowGeneration(true)}
|
||||
aria-expanded={showGeneration}
|
||||
aria-controls="generation-dialog"
|
||||
>
|
||||
Generate cases
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setShowFormatter(true)}
|
||||
aria-expanded={showFormatter}
|
||||
aria-controls="formatter-dialog"
|
||||
title={
|
||||
flavour === "ecmascript"
|
||||
? "Preview canonical literal/control escaping and validate it against exact snapshots"
|
||||
: "Open the formatter to see current flavour availability"
|
||||
}
|
||||
>
|
||||
Format
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setShowMinimizer(true)}
|
||||
aria-expanded={showMinimizer}
|
||||
aria-controls="minimize-dialog"
|
||||
>
|
||||
Minimize
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setShowComparison(true)}
|
||||
aria-expanded={showComparison}
|
||||
aria-controls="comparison-code-dialog"
|
||||
>
|
||||
Compare & code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setShowAnalysis(true)}
|
||||
aria-expanded={showAnalysis}
|
||||
aria-controls="analysis-dialog"
|
||||
>
|
||||
Analysis
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={flavour !== "pcre2"}
|
||||
title={
|
||||
flavour === "pcre2"
|
||||
? "Open the isolated PCRE2 automatic-callout viewer"
|
||||
: "Select PCRE2 to enable its automatic-callout trace"
|
||||
}
|
||||
onClick={() => setShowTrace(true)}
|
||||
aria-expanded={showTrace}
|
||||
aria-controls="trace-dialog"
|
||||
>
|
||||
PCRE trace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setShowReference((value) => !value)}
|
||||
aria-expanded={showReference}
|
||||
aria-controls="quick-reference-dialog"
|
||||
>
|
||||
Quick reference
|
||||
</button>
|
||||
@@ -1494,6 +1769,7 @@ export function Workbench() {
|
||||
className="secondary-button"
|
||||
onClick={() => setShowCapabilities((value) => !value)}
|
||||
aria-expanded={showCapabilities}
|
||||
aria-controls="capabilities-dialog"
|
||||
>
|
||||
Capabilities
|
||||
</button>
|
||||
@@ -1526,6 +1802,199 @@ export function Workbench() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ModalDialog
|
||||
id="generation-dialog"
|
||||
open={showGeneration}
|
||||
labelledBy="generation-heading"
|
||||
onClose={() => setShowGeneration(false)}
|
||||
className="generation-dialog"
|
||||
>
|
||||
{showGeneration ? (
|
||||
<GenerationPanel
|
||||
active={showGeneration}
|
||||
flavour={flavour}
|
||||
flavourVersion={flavourVersion}
|
||||
pattern={pattern}
|
||||
flags={flags}
|
||||
options={options}
|
||||
scanAll={scanAll}
|
||||
syntax={syntax}
|
||||
onAddToTests={addGeneratedTests}
|
||||
onSelectPatternRange={(range) => {
|
||||
const node = syntax
|
||||
? findSmallestNode(syntax.root, range)
|
||||
: undefined;
|
||||
if (node) {
|
||||
selectSyntaxNode(node);
|
||||
} else {
|
||||
setRequestedPatternRange(range);
|
||||
}
|
||||
setShowGeneration(false);
|
||||
}}
|
||||
onClose={() => setShowGeneration(false)}
|
||||
/>
|
||||
) : null}
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
id="formatter-dialog"
|
||||
open={showFormatter}
|
||||
labelledBy="formatter-heading"
|
||||
onClose={() => setShowFormatter(false)}
|
||||
className="formatter-dialog"
|
||||
>
|
||||
{showFormatter ? (
|
||||
<PatternFormatterPanel
|
||||
flavour={flavour}
|
||||
flavourVersion={flavourVersion}
|
||||
pattern={pattern}
|
||||
flags={flags}
|
||||
options={options}
|
||||
syntax={syntax}
|
||||
subject={subject}
|
||||
replacement={replacement}
|
||||
scanAll={scanAll}
|
||||
timeoutMs={timeoutMs}
|
||||
tests={tests}
|
||||
onApply={(value) => {
|
||||
changePattern(value);
|
||||
setShowFormatter(false);
|
||||
}}
|
||||
onClose={() => setShowFormatter(false)}
|
||||
/>
|
||||
) : null}
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
id="minimize-dialog"
|
||||
open={showMinimizer}
|
||||
labelledBy="minimize-heading"
|
||||
onClose={() => setShowMinimizer(false)}
|
||||
className="minimize-dialog"
|
||||
>
|
||||
{showMinimizer ? (
|
||||
<MinimizePanel
|
||||
activeFlavour={flavour}
|
||||
activeFlavourVersion={flavourVersion}
|
||||
activeFlags={flags}
|
||||
activeOptions={options}
|
||||
pattern={pattern}
|
||||
subject={subject}
|
||||
replacement={replacement}
|
||||
scanAll={scanAll}
|
||||
timeoutMs={timeoutMs}
|
||||
onUseSubject={(value) => {
|
||||
changeSubject(value);
|
||||
setShowMinimizer(false);
|
||||
}}
|
||||
onClose={() => setShowMinimizer(false)}
|
||||
/>
|
||||
) : null}
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
id="comparison-code-dialog"
|
||||
open={showComparison}
|
||||
labelledBy="comparison-code-heading"
|
||||
onClose={() => setShowComparison(false)}
|
||||
className="comparison-code-dialog"
|
||||
>
|
||||
{showComparison ? (
|
||||
<ComparisonCodePanel
|
||||
activeFlavour={flavour}
|
||||
activeFlags={flags}
|
||||
activeOptions={options}
|
||||
pattern={pattern}
|
||||
subject={subject}
|
||||
replacement={replacement}
|
||||
scanAll={scanAll}
|
||||
timeoutMs={timeoutMs}
|
||||
onClose={() => setShowComparison(false)}
|
||||
/>
|
||||
) : null}
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
id="quick-reference-dialog"
|
||||
open={showReference}
|
||||
labelledBy="reference-heading"
|
||||
onClose={() => setShowReference(false)}
|
||||
className="reference-dialog"
|
||||
>
|
||||
<QuickReference
|
||||
flavour={flavour}
|
||||
onUsePattern={(example) => {
|
||||
changePattern(example);
|
||||
setShowReference(false);
|
||||
}}
|
||||
onClose={() => setShowReference(false)}
|
||||
/>
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
id="capabilities-dialog"
|
||||
open={showCapabilities}
|
||||
labelledBy="capability-heading"
|
||||
onClose={() => setShowCapabilities(false)}
|
||||
className="capabilities-dialog"
|
||||
>
|
||||
<CapabilityPanel
|
||||
syntax={syntax}
|
||||
execution={execution}
|
||||
onClose={() => setShowCapabilities(false)}
|
||||
/>
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
id="trace-dialog"
|
||||
open={showTrace}
|
||||
labelledBy="trace-heading"
|
||||
onClose={() => setShowTrace(false)}
|
||||
className="trace-dialog"
|
||||
>
|
||||
<Pcre2TracePanel
|
||||
active={showTrace}
|
||||
flavour={flavour}
|
||||
pattern={pattern}
|
||||
flags={flags}
|
||||
options={options}
|
||||
subject={subject}
|
||||
timeoutMs={timeoutMs}
|
||||
onClose={() => setShowTrace(false)}
|
||||
onSelectRanges={(patternRange, subjectRange) => {
|
||||
setSelectedSyntax(undefined);
|
||||
setSelectedExtraction(undefined);
|
||||
setSelectedCaptureRow(undefined);
|
||||
setRequestedPatternRange(patternRange);
|
||||
setRequestedSubjectRange(subjectRange);
|
||||
setShowTrace(false);
|
||||
}}
|
||||
/>
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
id="analysis-dialog"
|
||||
open={showAnalysis}
|
||||
labelledBy="analysis-heading"
|
||||
onClose={() => setShowAnalysis(false)}
|
||||
className="analysis-dialog"
|
||||
>
|
||||
<AnalysisPanel
|
||||
active={showAnalysis}
|
||||
flavour={flavour}
|
||||
pattern={pattern}
|
||||
flags={flags}
|
||||
subject={subject}
|
||||
replacement={replacement}
|
||||
scanAll={scanAll}
|
||||
syntax={syntax}
|
||||
onClose={() => setShowAnalysis(false)}
|
||||
onSelectPatternRange={(range) => {
|
||||
const node = syntax
|
||||
? findSmallestNode(syntax.root, range)
|
||||
: undefined;
|
||||
if (node) {
|
||||
selectSyntaxNode(node);
|
||||
} else {
|
||||
setRequestedPatternRange(range);
|
||||
}
|
||||
setShowAnalysis(false);
|
||||
}}
|
||||
/>
|
||||
</ModalDialog>
|
||||
<div
|
||||
className={`run-status status-${runState.status}`}
|
||||
role="status"
|
||||
@@ -1596,7 +2065,7 @@ export function Workbench() {
|
||||
onSelect={(node) => selectSyntaxNode(node)}
|
||||
/>
|
||||
</div>
|
||||
{mode !== "tests" ? (
|
||||
{mode !== "tests" && mode !== "corpus" ? (
|
||||
<div className="result-workspace">
|
||||
<section className="panel editor-panel subject-panel">
|
||||
<header className="panel-heading">
|
||||
@@ -1715,6 +2184,7 @@ export function Workbench() {
|
||||
) : null}
|
||||
{mode === "replace" ? (
|
||||
<ReplacementPanel
|
||||
flavour={flavour}
|
||||
replacement={replacement}
|
||||
onReplacementChange={changeReplacement}
|
||||
syntax={replacementSyntax}
|
||||
@@ -1740,10 +2210,28 @@ export function Workbench() {
|
||||
sourceResultTruncated={execution?.truncated ?? false}
|
||||
/>
|
||||
) : null}
|
||||
<div hidden={mode !== "corpus"}>
|
||||
<CorpusPanel
|
||||
key={corpusSession}
|
||||
active={mode === "corpus"}
|
||||
flavour={flavour}
|
||||
flavourVersion={flavourVersion}
|
||||
options={options}
|
||||
pattern={pattern}
|
||||
flags={flags}
|
||||
captureMetadata={syntax?.captures ?? []}
|
||||
patternAccepted={syntax?.accepted === true}
|
||||
replacement={replacement}
|
||||
replacementAccepted={replacementSyntax?.accepted === true}
|
||||
onReplacementChange={changeReplacement}
|
||||
timeoutMs={timeoutMs}
|
||||
/>
|
||||
</div>
|
||||
{mode === "tests" ? (
|
||||
<UnitTestPanel
|
||||
tests={tests}
|
||||
results={testResults}
|
||||
currentFlavour={flavour}
|
||||
currentPattern={pattern}
|
||||
currentFlags={flags}
|
||||
currentScanAll={scanAll}
|
||||
@@ -1800,10 +2288,6 @@ export function Workbench() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showReference ? <QuickReference onInsert={changePattern} /> : null}
|
||||
{showCapabilities ? (
|
||||
<CapabilityPanel syntax={syntax} execution={execution} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
} from "./project.serialization";
|
||||
import type { RegexProjectV1 } from "./project.types";
|
||||
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
|
||||
import { parseRegexProject, parseRegexTests } from "./project.validation";
|
||||
import {
|
||||
RegexFlavourRegistry,
|
||||
type RegexFlavourDefinition,
|
||||
} from "../regex/flavours/flavour-registry";
|
||||
|
||||
const project: RegexProjectV1 = {
|
||||
schemaVersion: 1,
|
||||
@@ -58,6 +63,55 @@ describe("project import and export", () => {
|
||||
expect(serialized).toContain('"subject": "42"');
|
||||
});
|
||||
|
||||
it("retains validated generation provenance when private subjects are omitted", () => {
|
||||
const generatedTest = {
|
||||
...project.tests[0]!,
|
||||
generation: {
|
||||
kind: "generated" as const,
|
||||
generatorId: "regex-tools-ast-cases" as const,
|
||||
generatorVersion: "1" as const,
|
||||
seed: "project-seed-17",
|
||||
candidateId: "alternative:root:1",
|
||||
intendedOutcome: "match" as const,
|
||||
},
|
||||
};
|
||||
const imported = importProjectDocument(
|
||||
serializeProject(
|
||||
{ ...project, tests: [generatedTest] },
|
||||
{
|
||||
includeTestText: false,
|
||||
includeUnitTestSubjects: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(imported.tests[0]?.subject).toBe("");
|
||||
expect(imported.tests[0]?.generation).toEqual(generatedTest.generation);
|
||||
});
|
||||
|
||||
it("persists corpus mode without accepting ephemeral corpus content", () => {
|
||||
const imported = importProjectDocument(
|
||||
serializeProject(
|
||||
{ ...project, mode: "corpus" },
|
||||
{
|
||||
includeTestText: false,
|
||||
includeUnitTestSubjects: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(imported.mode).toBe("corpus");
|
||||
|
||||
expect(() =>
|
||||
importProjectDocument(
|
||||
JSON.stringify({
|
||||
...project,
|
||||
mode: "corpus",
|
||||
corpus: [{ name: "secret.txt", text: "secret" }],
|
||||
}),
|
||||
),
|
||||
).toThrow(/ephemeral/iu);
|
||||
});
|
||||
|
||||
it("preflights aggregate export size after applying privacy options", () => {
|
||||
const escapedSubject = "\0".repeat(2 * 1024 * 1024);
|
||||
const aggregateProject: RegexProjectV1 = {
|
||||
@@ -171,10 +225,39 @@ describe("project import and export", () => {
|
||||
).toThrow(/Test 1 subject exceeds.*UTF-8 byte limit/u);
|
||||
});
|
||||
|
||||
it("rejects unsupported flavour and option semantics instead of rewriting them", () => {
|
||||
it("accepts PCRE2 and rejects unsupported flavour or option semantics without rewriting", () => {
|
||||
expect(
|
||||
importProjectDocument(
|
||||
JSON.stringify({
|
||||
...project,
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "PCRE2 10.47",
|
||||
flags: ["g"],
|
||||
options: {
|
||||
matchLimit: 50_000,
|
||||
depthLimit: 500,
|
||||
heapLimitKib: 4_096,
|
||||
},
|
||||
tests: [],
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "PCRE2 10.47",
|
||||
flags: ["g"],
|
||||
options: {
|
||||
matchLimit: 50_000,
|
||||
depthLimit: 500,
|
||||
heapLimitKib: 4_096,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(() =>
|
||||
importProjectDocument(JSON.stringify({ ...project, flavour: "pcre2" })),
|
||||
).toThrow(/only supports ECMAScript/u);
|
||||
importProjectDocument(
|
||||
JSON.stringify({ ...project, flavourVersion: "ECMAScript 2026" }),
|
||||
),
|
||||
).toThrow(/flavour version.*unsupported/iu);
|
||||
expect(() =>
|
||||
importProjectDocument(
|
||||
JSON.stringify({
|
||||
@@ -182,7 +265,20 @@ describe("project import and export", () => {
|
||||
tests: [{ ...project.tests[0], flavour: "python" }],
|
||||
}),
|
||||
),
|
||||
).toThrow(/only supports ECMAScript/u);
|
||||
).toThrow(/only supports ECMAScript and PCRE2/u);
|
||||
expect(() =>
|
||||
importProjectDocument(
|
||||
JSON.stringify({
|
||||
...project,
|
||||
tests: [
|
||||
{
|
||||
...project.tests[0],
|
||||
flavourVersion: "ECMAScript 2026",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toThrow(/flavour version.*unsupported/iu);
|
||||
expect(() =>
|
||||
importProjectDocument(
|
||||
JSON.stringify({ ...project, options: { ignoreCase: true } }),
|
||||
@@ -214,6 +310,84 @@ describe("project import and export", () => {
|
||||
).toThrow(/test id.*duplicated/iu);
|
||||
});
|
||||
|
||||
it("keeps schema v1 while delegating future flavour fields to a contract", () => {
|
||||
const fixtureFlavour: RegexFlavourDefinition = {
|
||||
id: "pcre2",
|
||||
label: "Fixture flavour",
|
||||
versions: [
|
||||
{
|
||||
value: "fixture-version",
|
||||
label: "Fixture version",
|
||||
syntaxVersion: "fixture-version",
|
||||
},
|
||||
],
|
||||
defaultVersion: "fixture-version",
|
||||
flags: [{ value: "i", label: "Caseless" }],
|
||||
defaultFlags: [],
|
||||
options: [
|
||||
{
|
||||
name: "ungreedy",
|
||||
label: "Ungreedy",
|
||||
kind: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const registry = new RegexFlavourRegistry([fixtureFlavour]);
|
||||
const imported = parseRegexProject(
|
||||
{
|
||||
...project,
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "fixture-version",
|
||||
flags: ["i"],
|
||||
options: { ungreedy: true },
|
||||
tests: [],
|
||||
},
|
||||
registry,
|
||||
);
|
||||
const importedTests = parseRegexTests(
|
||||
[
|
||||
{
|
||||
...project.tests[0],
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "fixture-version",
|
||||
flags: ["i"],
|
||||
options: { ungreedy: true },
|
||||
},
|
||||
],
|
||||
registry,
|
||||
);
|
||||
|
||||
expect(imported).toEqual(
|
||||
expect.objectContaining({
|
||||
schemaVersion: 1,
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "fixture-version",
|
||||
flags: ["i"],
|
||||
options: { ungreedy: true },
|
||||
}),
|
||||
);
|
||||
expect(importedTests[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "fixture-version",
|
||||
flags: ["i"],
|
||||
options: { ungreedy: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("imports legacy schema-v1 projects without a flavour-version field", () => {
|
||||
const imported = parseRegexProject(project);
|
||||
|
||||
expect(imported.schemaVersion).toBe(1);
|
||||
expect(imported.flavour).toBe("ecmascript");
|
||||
expect(imported.flavourVersion).toBe(
|
||||
"ECMAScript 2025 syntax / current browser runtime",
|
||||
);
|
||||
expect(imported.tests[0]?.flavourVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates and preserves the supported per-test timeout limit", () => {
|
||||
const imported = importProjectDocument(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { RegexFlavourId } from "../regex/model/flavour";
|
||||
import type {
|
||||
RegexEngineOptions,
|
||||
RegexFlavourId,
|
||||
} from "../regex/model/flavour";
|
||||
import type { RegexResourceLimits } from "../regex/execution/request-limits";
|
||||
import type { ListTemplate } from "../regex/list/list-template";
|
||||
import type { RegexTestCase } from "../regex/tests/test-case.types";
|
||||
|
||||
export type WorkspaceMode = "match" | "replace" | "list" | "tests";
|
||||
export type WorkspaceMode = "match" | "replace" | "list" | "corpus" | "tests";
|
||||
|
||||
export interface RegexProjectV1 {
|
||||
readonly schemaVersion: 1;
|
||||
@@ -15,7 +18,7 @@ export interface RegexProjectV1 {
|
||||
readonly flavourVersion?: string;
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: Readonly<Record<string, boolean | number | string>>;
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly replacement?: string;
|
||||
readonly mode: WorkspaceMode;
|
||||
readonly testText?: {
|
||||
|
||||
@@ -17,14 +17,22 @@ import {
|
||||
utf8ByteLength,
|
||||
} from "../regex/execution/request-limits";
|
||||
import { assertProjectDocumentWithinLimit } from "./project-limits";
|
||||
import {
|
||||
AVAILABLE_REGEX_FLAVOURS,
|
||||
parseRegexFlags,
|
||||
parseRegexOptions,
|
||||
resolveRegexFlavourVersion,
|
||||
type RegexFlavourRegistry,
|
||||
} from "../regex/flavours/flavour-registry";
|
||||
import { parseGeneratedCaseProvenance } from "../regex/generation/generation-provenance";
|
||||
|
||||
const IMPLEMENTED_MODES = new Set<WorkspaceMode>([
|
||||
"match",
|
||||
"replace",
|
||||
"list",
|
||||
"corpus",
|
||||
"tests",
|
||||
]);
|
||||
const VALID_FLAGS = new Set("dgimsuvy".split(""));
|
||||
const MAXIMUM_PATTERN_UTF16 = MAXIMUM_REGEX_TEST_PATTERN_UTF16;
|
||||
const MAXIMUM_SUBJECT_UTF16 = MAXIMUM_REGEX_TEST_SUBJECT_UTF16;
|
||||
|
||||
@@ -58,44 +66,6 @@ function subjectValue(value: unknown, label: string): string {
|
||||
return subject;
|
||||
}
|
||||
|
||||
function parseFlags(value: unknown): readonly string[] {
|
||||
if (!Array.isArray(value)) throw new Error("Project flags must be an array.");
|
||||
const flags = value.map((flag) => stringValue(flag, "Flag", 1));
|
||||
if (
|
||||
new Set(flags).size !== flags.length ||
|
||||
flags.some((flag) => !VALID_FLAGS.has(flag))
|
||||
) {
|
||||
throw new Error("Project flags contain duplicates or unsupported values.");
|
||||
}
|
||||
if (flags.includes("u") && flags.includes("v")) {
|
||||
throw new Error("ECMAScript flags u and v cannot be combined.");
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function parseFlavour(value: unknown, label: string): "ecmascript" {
|
||||
const flavour = stringValue(value, label, 32);
|
||||
if (flavour !== "ecmascript") {
|
||||
throw new Error(
|
||||
`${label} ${JSON.stringify(flavour)} is unsupported; version 0.1.0 only supports ECMAScript.`,
|
||||
);
|
||||
}
|
||||
return flavour;
|
||||
}
|
||||
|
||||
function parseOptions(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, never>> {
|
||||
const options = value === undefined ? {} : objectValue(value, label);
|
||||
if (Object.keys(options).length > 0) {
|
||||
throw new Error(
|
||||
`${label} contains unsupported entries; version 0.1.0 does not implement ECMAScript engine options.`,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseTestResourceLimits(
|
||||
value: unknown,
|
||||
label: string,
|
||||
@@ -125,7 +95,10 @@ function parseTestResourceLimits(
|
||||
return { manualExecutionTimeoutMs: timeout };
|
||||
}
|
||||
|
||||
export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
|
||||
export function parseRegexTests(
|
||||
value: unknown,
|
||||
registry: RegexFlavourRegistry = AVAILABLE_REGEX_FLAVOURS,
|
||||
): readonly RegexTestCase[] {
|
||||
if (!Array.isArray(value)) throw new Error("Project tests must be an array.");
|
||||
if (value.length > MAXIMUM_REGEX_TESTS) {
|
||||
throw new Error(`Project contains more than ${MAXIMUM_REGEX_TESTS} tests.`);
|
||||
@@ -133,8 +106,22 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
|
||||
const testIds = new Set<string>();
|
||||
return value.map((candidate, index) => {
|
||||
const test = objectValue(candidate, `Test ${index + 1}`);
|
||||
const flavour = parseFlavour(test.flavour, `Test ${index + 1} flavour`);
|
||||
const options = parseOptions(test.options, `Test ${index + 1} options`);
|
||||
const flavour = registry.parse(test.flavour, `Test ${index + 1} flavour`);
|
||||
const flags = parseRegexFlags(
|
||||
test.flags,
|
||||
flavour,
|
||||
`Test ${index + 1} flags`,
|
||||
);
|
||||
const options = parseRegexOptions(
|
||||
test.options,
|
||||
flavour,
|
||||
`Test ${index + 1} options`,
|
||||
);
|
||||
const flavourVersion = resolveRegexFlavourVersion(
|
||||
test.flavourVersion,
|
||||
flavour,
|
||||
`Test ${index + 1} flavour version`,
|
||||
).value;
|
||||
const resourceLimits = parseTestResourceLimits(
|
||||
test.resourceLimits,
|
||||
`Test ${index + 1} resource limits`,
|
||||
@@ -261,6 +248,29 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
|
||||
`Imported test ${index + 1} uses unsupported expectation ${kind}.`,
|
||||
);
|
||||
}
|
||||
const generation =
|
||||
test.generation === undefined
|
||||
? undefined
|
||||
: parseGeneratedCaseProvenance(
|
||||
test.generation,
|
||||
`Test ${index + 1} generation`,
|
||||
);
|
||||
if (generation) {
|
||||
if (flavour.id !== "ecmascript") {
|
||||
throw new Error(
|
||||
`Test ${index + 1} generation provenance is supported only for ECMAScript generator v1.`,
|
||||
);
|
||||
}
|
||||
const expectedKind =
|
||||
generation.intendedOutcome === "match"
|
||||
? "should-match"
|
||||
: "should-not-match";
|
||||
if (parsedExpectation.kind !== expectedKind) {
|
||||
throw new Error(
|
||||
`Test ${index + 1} generation intendedOutcome does not match its ${parsedExpectation.kind} expectation.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const id = stringValue(test.id, `Test ${index + 1} id`, 128);
|
||||
if (id.length === 0) {
|
||||
throw new Error(`Test ${index + 1} id must not be empty.`);
|
||||
@@ -277,22 +287,14 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
|
||||
MAXIMUM_REGEX_TEST_NAME_UTF16,
|
||||
),
|
||||
enabled: test.enabled !== false,
|
||||
flavour,
|
||||
...(test.flavourVersion === undefined
|
||||
? {}
|
||||
: {
|
||||
flavourVersion: stringValue(
|
||||
test.flavourVersion,
|
||||
`Test ${index + 1} flavour version`,
|
||||
128,
|
||||
),
|
||||
}),
|
||||
flavour: flavour.id,
|
||||
...(test.flavourVersion === undefined ? {} : { flavourVersion }),
|
||||
pattern: stringValue(
|
||||
test.pattern,
|
||||
`Test ${index + 1} pattern`,
|
||||
MAXIMUM_REGEX_TEST_PATTERN_UTF16,
|
||||
),
|
||||
flags: parseFlags(test.flags),
|
||||
flags,
|
||||
options,
|
||||
...(test.scanAll === undefined
|
||||
? {}
|
||||
@@ -313,28 +315,44 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
|
||||
}),
|
||||
...(resourceLimits === undefined ? {} : { resourceLimits }),
|
||||
expectation: parsedExpectation,
|
||||
...(generation === undefined ? {} : { generation }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseRegexProject(value: unknown): RegexProjectV1 {
|
||||
export function parseRegexProject(
|
||||
value: unknown,
|
||||
registry: RegexFlavourRegistry = AVAILABLE_REGEX_FLAVOURS,
|
||||
): RegexProjectV1 {
|
||||
const input = objectValue(value);
|
||||
if (input.schemaVersion !== 1) {
|
||||
throw new Error("Only regex-tools project schema version 1 is supported.");
|
||||
}
|
||||
const flavour = parseFlavour(input.flavour, "Project flavour");
|
||||
const options = parseOptions(input.options, "Project options");
|
||||
const flavour = registry.parse(input.flavour, "Project flavour");
|
||||
const flavourVersion = resolveRegexFlavourVersion(
|
||||
input.flavourVersion,
|
||||
flavour,
|
||||
"Project flavour version",
|
||||
).value;
|
||||
const flags = parseRegexFlags(input.flags, flavour, "Project flags");
|
||||
const options = parseRegexOptions(input.options, flavour, "Project options");
|
||||
if (input.resourceOverrides !== undefined) {
|
||||
const overrides = objectValue(
|
||||
input.resourceOverrides,
|
||||
"Project resource overrides",
|
||||
);
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
throw new Error(
|
||||
"Project resource overrides are not implemented in version 0.1.0.",
|
||||
);
|
||||
throw new Error("Project resource overrides are not implemented.");
|
||||
}
|
||||
}
|
||||
const corpusFields = Object.keys(input).filter((key) =>
|
||||
key.toLocaleLowerCase("en-US").startsWith("corpus"),
|
||||
);
|
||||
if (corpusFields.length > 0) {
|
||||
throw new Error(
|
||||
`Corpus documents and results are ephemeral and cannot be imported from a schema-v1 project (${corpusFields.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
const mode = stringValue(input.mode, "Project mode", 16) as WorkspaceMode;
|
||||
if (!IMPLEMENTED_MODES.has(mode)) {
|
||||
throw new Error(`Project mode ${mode} is not implemented in this release.`);
|
||||
@@ -358,10 +376,10 @@ export function parseRegexProject(value: unknown): RegexProjectV1 {
|
||||
typeof input.createdAt === "string" ? input.createdAt.slice(0, 64) : now,
|
||||
updatedAt:
|
||||
typeof input.updatedAt === "string" ? input.updatedAt.slice(0, 64) : now,
|
||||
flavour,
|
||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
||||
flavour: flavour.id,
|
||||
flavourVersion,
|
||||
pattern: stringValue(input.pattern, "Pattern", MAXIMUM_PATTERN_UTF16),
|
||||
flags: parseFlags(input.flags),
|
||||
flags,
|
||||
options,
|
||||
replacement:
|
||||
input.replacement === undefined
|
||||
@@ -381,7 +399,7 @@ export function parseRegexProject(value: unknown): RegexProjectV1 {
|
||||
: {}),
|
||||
},
|
||||
listTemplate: parseListTemplate(listSource),
|
||||
tests: parseRegexTests(input.tests ?? []),
|
||||
tests: parseRegexTests(input.tests ?? [], registry),
|
||||
ui: {
|
||||
live:
|
||||
typeof input.ui === "object" &&
|
||||
|
||||
161
src/regex/analysis/AnalysisSupervisor.test.ts
Normal file
161
src/regex/analysis/AnalysisSupervisor.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
WorkerLike,
|
||||
WorkerRequestError,
|
||||
} from "../execution/WorkerSupervisor";
|
||||
import {
|
||||
WORKER_PROTOCOL_VERSION,
|
||||
type WorkerRequest,
|
||||
type WorkerResponse,
|
||||
} from "../execution/worker-protocol";
|
||||
import { AnalysisSupervisor } from "./AnalysisSupervisor";
|
||||
import type {
|
||||
AnalysisWorkerOperation,
|
||||
AnalysisWorkerResult,
|
||||
} from "./analysis.types";
|
||||
|
||||
class ResponsiveWorker implements WorkerLike {
|
||||
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
terminated = false;
|
||||
|
||||
postMessage(value: unknown): void {
|
||||
const request = value as WorkerRequest<AnalysisWorkerOperation>;
|
||||
let payload: AnalysisWorkerResult;
|
||||
if (request.payload.kind === "identity") {
|
||||
payload = {
|
||||
kind: "identity",
|
||||
result: {
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Fixture runtime",
|
||||
runtimeVersion: "Fixture runtime",
|
||||
nativeOffsetUnit: "utf16",
|
||||
},
|
||||
};
|
||||
} else if (request.payload.kind === "benchmark-sample") {
|
||||
payload = {
|
||||
kind: "benchmark-sample",
|
||||
result: {
|
||||
accepted: true,
|
||||
effectiveFlags: "dg",
|
||||
subjectBytes: 1,
|
||||
subjectUtf16: 1,
|
||||
compileMs: 1,
|
||||
firstMatchMs: 1,
|
||||
allMatchesMs: 1,
|
||||
replacementMs: 1,
|
||||
throughputBytesPerSecond: 1_000,
|
||||
matchCount: 1,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 1,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
kind: "growth-probe",
|
||||
result: {
|
||||
accepted: true,
|
||||
effectiveFlags: "d",
|
||||
subjectBytes: 1,
|
||||
subjectUtf16: 1,
|
||||
executionMs: 1,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
const response: WorkerResponse<AnalysisWorkerResult> = {
|
||||
protocolVersion: WORKER_PROTOCOL_VERSION,
|
||||
requestId: request.requestId,
|
||||
generation: request.generation,
|
||||
ok: true,
|
||||
payload,
|
||||
};
|
||||
globalThis.queueMicrotask(() => {
|
||||
if (!this.terminated) {
|
||||
this.onmessage?.(new MessageEvent("message", { data: response }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
class SilentWorker implements WorkerLike {
|
||||
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
terminated = false;
|
||||
|
||||
postMessage(): void {}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe("AnalysisSupervisor", () => {
|
||||
it("routes typed identity, benchmark and growth responses", async () => {
|
||||
const created: ResponsiveWorker[] = [];
|
||||
const supervisor = new AnalysisSupervisor(() => {
|
||||
const next = new ResponsiveWorker();
|
||||
created.push(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
await expect(supervisor.identity(100)).resolves.toEqual(
|
||||
expect.objectContaining({ engineVersion: "Fixture runtime" }),
|
||||
);
|
||||
await expect(
|
||||
supervisor.benchmarkSample(
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
pattern: "a",
|
||||
flags: [],
|
||||
subject: "a",
|
||||
replacement: "x",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
},
|
||||
100,
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ compileMs: 1 }));
|
||||
await expect(
|
||||
supervisor.growthProbe(
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
pattern: "a",
|
||||
flags: [],
|
||||
subject: "b",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
},
|
||||
100,
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ matched: false }));
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
supervisor.dispose();
|
||||
expect(created[0]?.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it("terminates and rejects an active request on cancellation", async () => {
|
||||
const worker = new SilentWorker();
|
||||
const supervisor = new AnalysisSupervisor(() => worker);
|
||||
const pending = supervisor.identity(1_000);
|
||||
|
||||
supervisor.cancel();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
kind: "cancelled",
|
||||
} satisfies Partial<WorkerRequestError>);
|
||||
expect(worker.terminated).toBe(true);
|
||||
supervisor.dispose();
|
||||
});
|
||||
});
|
||||
94
src/regex/analysis/AnalysisSupervisor.ts
Normal file
94
src/regex/analysis/AnalysisSupervisor.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
WorkerSupervisor,
|
||||
type WorkerFactory,
|
||||
} from "../execution/WorkerSupervisor";
|
||||
import type {
|
||||
AnalysisEngineIdentity,
|
||||
AnalysisWorkerOperation,
|
||||
AnalysisWorkerResult,
|
||||
BenchmarkSubject,
|
||||
BenchmarkWorkerSample,
|
||||
GrowthProbeSubject,
|
||||
GrowthWorkerSample,
|
||||
} from "./analysis.types";
|
||||
|
||||
export interface AnalysisWorkerClient {
|
||||
identity(timeoutMs: number): Promise<AnalysisEngineIdentity>;
|
||||
benchmarkSample(
|
||||
request: BenchmarkSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<BenchmarkWorkerSample>;
|
||||
growthProbe(
|
||||
request: GrowthProbeSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<GrowthWorkerSample>;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const createAnalysisWorker: WorkerFactory = () =>
|
||||
new Worker(new URL("../../workers/analysis.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: "regex-tools-ecmascript-analysis",
|
||||
});
|
||||
|
||||
export class AnalysisSupervisor implements AnalysisWorkerClient {
|
||||
readonly #supervisor: WorkerSupervisor<
|
||||
AnalysisWorkerOperation,
|
||||
AnalysisWorkerResult
|
||||
>;
|
||||
|
||||
constructor(workerFactory: WorkerFactory = createAnalysisWorker) {
|
||||
this.#supervisor = new WorkerSupervisor(
|
||||
"ECMAScript analysis worker",
|
||||
workerFactory,
|
||||
);
|
||||
}
|
||||
|
||||
async identity(timeoutMs: number): Promise<AnalysisEngineIdentity> {
|
||||
const response = await this.#supervisor.run(
|
||||
{ kind: "identity" },
|
||||
timeoutMs,
|
||||
);
|
||||
if (response.kind !== "identity") {
|
||||
throw new Error("Analysis worker returned the wrong identity response.");
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async benchmarkSample(
|
||||
request: BenchmarkSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<BenchmarkWorkerSample> {
|
||||
const response = await this.#supervisor.run(
|
||||
{ kind: "benchmark-sample", request },
|
||||
timeoutMs,
|
||||
);
|
||||
if (response.kind !== "benchmark-sample") {
|
||||
throw new Error("Analysis worker returned the wrong benchmark response.");
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async growthProbe(
|
||||
request: GrowthProbeSubject,
|
||||
timeoutMs: number,
|
||||
): Promise<GrowthWorkerSample> {
|
||||
const response = await this.#supervisor.run(
|
||||
{ kind: "growth-probe", request },
|
||||
timeoutMs,
|
||||
);
|
||||
if (response.kind !== "growth-probe") {
|
||||
throw new Error("Analysis worker returned the wrong growth response.");
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.#supervisor.cancel();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#supervisor.dispose();
|
||||
}
|
||||
}
|
||||
36
src/regex/analysis/analysis-limits.ts
Normal file
36
src/regex/analysis/analysis-limits.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DEFAULT_REGEX_LIMITS } from "../execution/request-limits";
|
||||
import type { BenchmarkSettings, GrowthSettings } from "./analysis.types";
|
||||
|
||||
export const ANALYSIS_LIMITS = {
|
||||
maximumWarmupIterations: 100,
|
||||
maximumMeasuredIterations: 1_000,
|
||||
maximumGrowthSteps: 24,
|
||||
maximumGrowthFragmentUtf16: 4_096,
|
||||
maximumGrowthAffixUtf16: 16_384,
|
||||
maximumGrowthRepetitions: 10_000_000,
|
||||
maximumReplacementBenchmarkOutputUtf16: 8 * 1024 * 1024,
|
||||
minimumSampleTimeoutMs: 25,
|
||||
maximumSampleTimeoutMs: DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
|
||||
maximumAnalysisWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_BENCHMARK_SETTINGS: BenchmarkSettings = {
|
||||
warmupIterations: 3,
|
||||
measuredIterations: 15,
|
||||
sampleTimeoutMs: 2_000,
|
||||
maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
|
||||
};
|
||||
|
||||
export const DEFAULT_GROWTH_SETTINGS: GrowthSettings = {
|
||||
prefix: "",
|
||||
repeatedFragment: "a",
|
||||
suffix: "!",
|
||||
startingRepetitions: 16,
|
||||
maximumRepetitions: 16_384,
|
||||
multiplier: 2,
|
||||
maximumSteps: 11,
|
||||
sampleTimeoutMs: 1_000,
|
||||
maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs,
|
||||
maximumSubjectBytes: 1024 * 1024,
|
||||
normalizedGrowthThreshold: 4,
|
||||
};
|
||||
336
src/regex/analysis/analysis-runner.test.ts
Normal file
336
src/regex/analysis/analysis-runner.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkerRequestError } from "../execution/WorkerSupervisor";
|
||||
import {
|
||||
runGrowthAnalysis,
|
||||
runRegexBenchmark,
|
||||
validateBenchmarkRequest,
|
||||
validateGrowthRequest,
|
||||
} from "./analysis-runner";
|
||||
import type { AnalysisWorkerClient } from "./AnalysisSupervisor";
|
||||
import type {
|
||||
BenchmarkWorkerSample,
|
||||
GrowthWorkerSample,
|
||||
RegexBenchmarkRequest,
|
||||
GrowthAnalysisRequest,
|
||||
} from "./analysis.types";
|
||||
|
||||
function benchmarkSample(
|
||||
milliseconds: number,
|
||||
overrides: Partial<BenchmarkWorkerSample> = {},
|
||||
): BenchmarkWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "dg",
|
||||
subjectBytes: 3,
|
||||
subjectUtf16: 3,
|
||||
compileMs: milliseconds,
|
||||
firstMatchMs: milliseconds + 1,
|
||||
allMatchesMs: milliseconds + 2,
|
||||
replacementMs: milliseconds + 3,
|
||||
throughputBytesPerSecond: milliseconds * 100,
|
||||
matchCount: 1,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function growthSample(
|
||||
executionMs: number,
|
||||
overrides: Partial<GrowthWorkerSample> = {},
|
||||
): GrowthWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "d",
|
||||
subjectBytes: 1,
|
||||
subjectUtf16: 1,
|
||||
executionMs,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function client(
|
||||
overrides: Partial<AnalysisWorkerClient> = {},
|
||||
): AnalysisWorkerClient {
|
||||
return {
|
||||
identity: vi.fn().mockResolvedValue({
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Test Browser 1",
|
||||
runtimeVersion: "Test Browser 1",
|
||||
nativeOffsetUnit: "utf16",
|
||||
}),
|
||||
benchmarkSample: vi.fn().mockResolvedValue(benchmarkSample(1)),
|
||||
growthProbe: vi.fn().mockResolvedValue(growthSample(1)),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function benchmarkRequest(
|
||||
overrides: Partial<RegexBenchmarkRequest> = {},
|
||||
): RegexBenchmarkRequest {
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
pattern: "a+",
|
||||
flags: ["g"],
|
||||
subject: "aaa",
|
||||
replacement: "x",
|
||||
scanAll: true,
|
||||
maximumMatches: 10_000,
|
||||
settings: {
|
||||
warmupIterations: 1,
|
||||
measuredIterations: 3,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function growthRequest(
|
||||
overrides: Partial<GrowthAnalysisRequest> = {},
|
||||
): GrowthAnalysisRequest {
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
pattern: "(a+)+$",
|
||||
flags: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 10_000,
|
||||
settings: {
|
||||
prefix: "",
|
||||
repeatedFragment: "a",
|
||||
suffix: "!",
|
||||
startingRepetitions: 16,
|
||||
maximumRepetitions: 64,
|
||||
multiplier: 2,
|
||||
maximumSteps: 3,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
maximumSubjectBytes: 1_024,
|
||||
normalizedGrowthThreshold: 4,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bounded analysis runner", () => {
|
||||
it("keeps cold use, warm-up and measured statistics separate", async () => {
|
||||
const samples = [
|
||||
benchmarkSample(9),
|
||||
benchmarkSample(99),
|
||||
benchmarkSample(1),
|
||||
benchmarkSample(2),
|
||||
benchmarkSample(10),
|
||||
];
|
||||
const worker = client({
|
||||
benchmarkSample: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(samples.shift()!)),
|
||||
});
|
||||
const phases: string[] = [];
|
||||
|
||||
const result = await runRegexBenchmark(benchmarkRequest(), worker, {
|
||||
onProgress: (value) => phases.push(value.phase),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.coldSample?.compileMs).toBe(9);
|
||||
expect(result.completedWarmups).toBe(1);
|
||||
expect(result.completedMeasuredIterations).toBe(3);
|
||||
expect(result.warmStatistics.compileMs).toEqual({
|
||||
count: 3,
|
||||
minimum: 1,
|
||||
median: 2,
|
||||
p95: 10,
|
||||
maximum: 10,
|
||||
});
|
||||
expect(result.identity?.engineVersion).toBe("Test Browser 1");
|
||||
expect(phases).toEqual([
|
||||
"starting-worker",
|
||||
"cold-sample",
|
||||
"warming-up",
|
||||
"measuring",
|
||||
"measuring",
|
||||
"measuring",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns partial measured samples and a distinct timeout state", async () => {
|
||||
let calls = 0;
|
||||
const worker = client({
|
||||
benchmarkSample: vi.fn().mockImplementation(() => {
|
||||
calls += 1;
|
||||
if (calls === 3) {
|
||||
return Promise.reject(
|
||||
new WorkerRequestError("timeout", "sample timed out"),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(benchmarkSample(calls));
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runRegexBenchmark(
|
||||
benchmarkRequest({
|
||||
settings: {
|
||||
warmupIterations: 0,
|
||||
measuredIterations: 3,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
}),
|
||||
worker,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("timeout");
|
||||
expect(result.completedMeasuredIterations).toBe(1);
|
||||
expect(result.stoppedReason).toContain("worker was terminated");
|
||||
});
|
||||
|
||||
it("does not mislabel an aggregate wall limit as a regex timeout", async () => {
|
||||
let time = 0;
|
||||
const clock = { now: () => time };
|
||||
const worker = client({
|
||||
identity: vi.fn().mockImplementation(async () => {
|
||||
time = 950;
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: "Test Browser 1",
|
||||
runtimeVersion: "Test Browser 1",
|
||||
nativeOffsetUnit: "utf16",
|
||||
} as const;
|
||||
}),
|
||||
benchmarkSample: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new WorkerRequestError("timeout", "wall stop")),
|
||||
});
|
||||
|
||||
const result = await runRegexBenchmark(
|
||||
benchmarkRequest({
|
||||
settings: {
|
||||
warmupIterations: 0,
|
||||
measuredIterations: 1,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
}),
|
||||
worker,
|
||||
{},
|
||||
clock,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("wall-time-limit");
|
||||
expect(result.stoppedReason).toContain("aggregate wall-time");
|
||||
});
|
||||
|
||||
it("rejects out-of-bound benchmark and growth settings before worker use", () => {
|
||||
expect(() =>
|
||||
validateBenchmarkRequest(
|
||||
benchmarkRequest({
|
||||
settings: {
|
||||
warmupIterations: 101,
|
||||
measuredIterations: 1,
|
||||
sampleTimeoutMs: 100,
|
||||
maximumWallTimeMs: 1_000,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/Warm-up iterations/u);
|
||||
expect(() =>
|
||||
validateGrowthRequest(
|
||||
growthRequest({
|
||||
settings: {
|
||||
...growthRequest().settings,
|
||||
repeatedFragment: "",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/must not be empty/u);
|
||||
});
|
||||
|
||||
it("stops on observed normalized growth and records dynamic evidence", async () => {
|
||||
const probes = [growthSample(0.5), growthSample(10)];
|
||||
const worker = client({
|
||||
growthProbe: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(probes.shift()!)),
|
||||
});
|
||||
|
||||
const result = await runGrowthAnalysis(growthRequest(), worker);
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.stopReason).toBe("growth-threshold");
|
||||
expect(result.samples).toHaveLength(2);
|
||||
expect(result.samples[1]?.normalizedGrowth).toBeGreaterThan(4);
|
||||
expect(result.dynamicFindings[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
rule: "observed-growth",
|
||||
evidence: "dynamically-observed",
|
||||
confidence: "medium",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes an observed timeout from a crash", async () => {
|
||||
const worker = client({
|
||||
growthProbe: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new WorkerRequestError("timeout", "probe timed out"),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await runGrowthAnalysis(growthRequest(), worker);
|
||||
|
||||
expect(result.status).toBe("timeout");
|
||||
expect(result.stopReason).toBe("timeout");
|
||||
expect(result.samples[0]?.status).toBe("timeout");
|
||||
expect(result.dynamicFindings[0]?.rule).toBe("observed-timeout");
|
||||
});
|
||||
|
||||
it("reports a worker crash without relabelling it as a timeout", async () => {
|
||||
const worker = client({
|
||||
growthProbe: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new WorkerRequestError("crash", "fixture crash")),
|
||||
});
|
||||
|
||||
const result = await runGrowthAnalysis(growthRequest(), worker);
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.stopReason).toBe("crash");
|
||||
expect(result.samples[0]?.status).toBe("crash");
|
||||
expect(result.dynamicFindings).toEqual([]);
|
||||
expect(result.stoppedReason).toContain("fixture crash");
|
||||
});
|
||||
|
||||
it("preflights generated bytes and honours cancellation without allocating a subject", async () => {
|
||||
const worker = client();
|
||||
const tooLarge = await runGrowthAnalysis(
|
||||
growthRequest({
|
||||
settings: {
|
||||
...growthRequest().settings,
|
||||
prefix: "prefix",
|
||||
maximumSubjectBytes: 8,
|
||||
},
|
||||
}),
|
||||
worker,
|
||||
);
|
||||
expect(tooLarge.stopReason).toBe("maximum-subject-size");
|
||||
expect(worker.growthProbe).not.toHaveBeenCalled();
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const cancelled = await runGrowthAnalysis(growthRequest(), client(), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
expect(cancelled.stopReason).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
819
src/regex/analysis/analysis-runner.ts
Normal file
819
src/regex/analysis/analysis-runner.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
import { WorkerRequestError } from "../execution/WorkerSupervisor";
|
||||
import {
|
||||
DEFAULT_REGEX_LIMITS,
|
||||
utf8ByteLength,
|
||||
} from "../execution/request-limits";
|
||||
import { ANALYSIS_LIMITS } from "./analysis-limits";
|
||||
import type { AnalysisWorkerClient } from "./AnalysisSupervisor";
|
||||
import type {
|
||||
AnalysisProgress,
|
||||
AnalysisRunOptions,
|
||||
AnalysisRunStatus,
|
||||
BenchmarkWorkerSample,
|
||||
GrowthAnalysisRequest,
|
||||
GrowthAnalysisResult,
|
||||
GrowthSample,
|
||||
GrowthStopReason,
|
||||
RegexBenchmarkRequest,
|
||||
RegexBenchmarkResult,
|
||||
RegexRiskFinding,
|
||||
} from "./analysis.types";
|
||||
import { summarizeBenchmarkSamples } from "./statistics";
|
||||
|
||||
interface Clock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
const SYSTEM_CLOCK: Clock = {
|
||||
now: () => performance.now(),
|
||||
};
|
||||
|
||||
function assertInteger(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFiniteRange(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): void {
|
||||
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBenchmarkRequest(request: RegexBenchmarkRequest): void {
|
||||
if (request.flavour !== "ecmascript") {
|
||||
throw new RangeError("Benchmarking currently supports ECMAScript only.");
|
||||
}
|
||||
if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
|
||||
throw new RangeError("Pattern exceeds the configured hard limit.");
|
||||
}
|
||||
if (
|
||||
utf8ByteLength(request.subject) >
|
||||
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Benchmark subject exceeds the configured hard limit.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.replacement.length >
|
||||
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Benchmark replacement exceeds the configured hard limit.",
|
||||
);
|
||||
}
|
||||
assertInteger(
|
||||
request.maximumMatches,
|
||||
"Maximum matches",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.warmupIterations,
|
||||
"Warm-up iterations",
|
||||
0,
|
||||
ANALYSIS_LIMITS.maximumWarmupIterations,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.measuredIterations,
|
||||
"Measured iterations",
|
||||
1,
|
||||
ANALYSIS_LIMITS.maximumMeasuredIterations,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.sampleTimeoutMs,
|
||||
"Sample timeout",
|
||||
ANALYSIS_LIMITS.minimumSampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumSampleTimeoutMs,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumWallTimeMs,
|
||||
"Benchmark wall time",
|
||||
request.settings.sampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumAnalysisWallTimeMs,
|
||||
);
|
||||
if (
|
||||
request.settings.warmupIterations +
|
||||
request.settings.measuredIterations +
|
||||
1 >
|
||||
DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Benchmark exceeds the ${DEFAULT_REGEX_LIMITS.maximumBenchmarkIterations.toLocaleString()}-iteration limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateGrowthRequest(request: GrowthAnalysisRequest): void {
|
||||
if (request.flavour !== "ecmascript") {
|
||||
throw new RangeError(
|
||||
"Dynamic growth analysis currently supports ECMAScript only.",
|
||||
);
|
||||
}
|
||||
if (request.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
|
||||
throw new RangeError("Pattern exceeds the configured hard limit.");
|
||||
}
|
||||
assertInteger(
|
||||
request.maximumMatches,
|
||||
"Maximum matches",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||
);
|
||||
if (request.settings.repeatedFragment.length === 0) {
|
||||
throw new RangeError("Repeated fragment must not be empty.");
|
||||
}
|
||||
if (
|
||||
request.settings.repeatedFragment.length >
|
||||
ANALYSIS_LIMITS.maximumGrowthFragmentUtf16
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Repeated fragment exceeds ${ANALYSIS_LIMITS.maximumGrowthFragmentUtf16.toLocaleString()} UTF-16 units.`,
|
||||
);
|
||||
}
|
||||
for (const [label, value] of [
|
||||
["Growth prefix", request.settings.prefix],
|
||||
["Growth suffix", request.settings.suffix],
|
||||
] as const) {
|
||||
if (value.length > ANALYSIS_LIMITS.maximumGrowthAffixUtf16) {
|
||||
throw new RangeError(
|
||||
`${label} exceeds ${ANALYSIS_LIMITS.maximumGrowthAffixUtf16.toLocaleString()} UTF-16 units.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertInteger(
|
||||
request.settings.startingRepetitions,
|
||||
"Starting repetitions",
|
||||
1,
|
||||
ANALYSIS_LIMITS.maximumGrowthRepetitions,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumRepetitions,
|
||||
"Maximum repetitions",
|
||||
request.settings.startingRepetitions,
|
||||
ANALYSIS_LIMITS.maximumGrowthRepetitions,
|
||||
);
|
||||
assertFiniteRange(request.settings.multiplier, "Growth multiplier", 1.1, 10);
|
||||
assertInteger(
|
||||
request.settings.maximumSteps,
|
||||
"Maximum growth steps",
|
||||
1,
|
||||
ANALYSIS_LIMITS.maximumGrowthSteps,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.sampleTimeoutMs,
|
||||
"Sample timeout",
|
||||
ANALYSIS_LIMITS.minimumSampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumSampleTimeoutMs,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumWallTimeMs,
|
||||
"Growth wall time",
|
||||
request.settings.sampleTimeoutMs,
|
||||
ANALYSIS_LIMITS.maximumAnalysisWallTimeMs,
|
||||
);
|
||||
assertInteger(
|
||||
request.settings.maximumSubjectBytes,
|
||||
"Maximum generated subject bytes",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes,
|
||||
);
|
||||
assertFiniteRange(
|
||||
request.settings.normalizedGrowthThreshold,
|
||||
"Normalized growth threshold",
|
||||
1.1,
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
function errorKind(
|
||||
error: unknown,
|
||||
): "timeout" | "cancelled" | "crash" | "error" {
|
||||
if (error instanceof WorkerRequestError) {
|
||||
if (error.kind === "timeout") return "timeout";
|
||||
if (error.kind === "cancelled") return "cancelled";
|
||||
if (error.kind === "crash") return "crash";
|
||||
return "error";
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"kind" in error &&
|
||||
typeof error.kind === "string"
|
||||
) {
|
||||
if (
|
||||
error.kind === "timeout" ||
|
||||
error.kind === "cancelled" ||
|
||||
error.kind === "crash"
|
||||
) {
|
||||
return error.kind;
|
||||
}
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function aborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
function timeoutWithinDeadline(
|
||||
configuredTimeoutMs: number,
|
||||
deadline: number,
|
||||
clock: Clock,
|
||||
): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(configuredTimeoutMs, Math.floor(deadline - clock.now())),
|
||||
);
|
||||
}
|
||||
|
||||
function benchmarkResult(
|
||||
request: RegexBenchmarkRequest,
|
||||
started: number,
|
||||
clock: Clock,
|
||||
values: {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: RegexBenchmarkResult["identity"];
|
||||
readonly coldStartMs?: number;
|
||||
readonly coldSample?: BenchmarkWorkerSample;
|
||||
readonly warmSamples: readonly BenchmarkWorkerSample[];
|
||||
readonly completedWarmups: number;
|
||||
readonly stoppedReason?: string;
|
||||
readonly warnings: readonly string[];
|
||||
},
|
||||
): RegexBenchmarkResult {
|
||||
return {
|
||||
status: values.status,
|
||||
...(values.identity ? { identity: values.identity } : {}),
|
||||
...(values.coldStartMs === undefined
|
||||
? {}
|
||||
: { coldStartMs: values.coldStartMs }),
|
||||
...(values.coldSample ? { coldSample: values.coldSample } : {}),
|
||||
warmSamples: values.warmSamples,
|
||||
warmStatistics: summarizeBenchmarkSamples(values.warmSamples),
|
||||
completedWarmups: values.completedWarmups,
|
||||
completedMeasuredIterations: values.warmSamples.length,
|
||||
requestedSettings: request.settings,
|
||||
wallTimeMs: Math.max(0, clock.now() - started),
|
||||
...(values.stoppedReason ? { stoppedReason: values.stoppedReason } : {}),
|
||||
warnings: values.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function progress(options: AnalysisRunOptions, value: AnalysisProgress): void {
|
||||
options.onProgress?.(value);
|
||||
}
|
||||
|
||||
export async function runRegexBenchmark(
|
||||
request: RegexBenchmarkRequest,
|
||||
client: AnalysisWorkerClient,
|
||||
options: AnalysisRunOptions = {},
|
||||
clock: Clock = SYSTEM_CLOCK,
|
||||
): Promise<RegexBenchmarkResult> {
|
||||
validateBenchmarkRequest(request);
|
||||
const started = clock.now();
|
||||
const deadline = started + request.settings.maximumWallTimeMs;
|
||||
let identity: RegexBenchmarkResult["identity"];
|
||||
let coldStartMs: number | undefined;
|
||||
let coldSample: BenchmarkWorkerSample | undefined;
|
||||
let completedWarmups = 0;
|
||||
const warmSamples: BenchmarkWorkerSample[] = [];
|
||||
let requestWasWallLimited = false;
|
||||
const nextRequestTimeout = () => {
|
||||
requestWasWallLimited =
|
||||
deadline - clock.now() < request.settings.sampleTimeoutMs;
|
||||
return timeoutWithinDeadline(
|
||||
request.settings.sampleTimeoutMs,
|
||||
deadline,
|
||||
clock,
|
||||
);
|
||||
};
|
||||
const warnings = [
|
||||
"One browser, pattern and subject are not a complete performance characterization.",
|
||||
"Native browser and WebAssembly engine timings are not directly comparable.",
|
||||
"Displayed precision does not imply nanosecond measurement accuracy.",
|
||||
];
|
||||
|
||||
const partial = (
|
||||
status: AnalysisRunStatus,
|
||||
stoppedReason: string,
|
||||
): RegexBenchmarkResult =>
|
||||
benchmarkResult(request, started, clock, {
|
||||
status,
|
||||
identity,
|
||||
coldStartMs,
|
||||
coldSample,
|
||||
warmSamples,
|
||||
completedWarmups,
|
||||
stoppedReason,
|
||||
warnings,
|
||||
});
|
||||
|
||||
try {
|
||||
if (aborted(options.signal)) {
|
||||
return partial("cancelled", "Benchmark cancelled before it started.");
|
||||
}
|
||||
progress(options, {
|
||||
phase: "starting-worker",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Starting a fresh ECMAScript analysis worker…",
|
||||
});
|
||||
const coldStart = clock.now();
|
||||
identity = await client.identity(nextRequestTimeout());
|
||||
coldStartMs = Math.max(0, clock.now() - coldStart);
|
||||
|
||||
if (clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
progress(options, {
|
||||
phase: "cold-sample",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Measuring the first engine use separately…",
|
||||
});
|
||||
coldSample = await client.benchmarkSample(request, nextRequestTimeout());
|
||||
if (!coldSample.accepted) {
|
||||
return partial(
|
||||
"error",
|
||||
coldSample.compileError ??
|
||||
"The native ECMAScript engine rejected the benchmark pattern.",
|
||||
);
|
||||
}
|
||||
|
||||
for (let index = 0; index < request.settings.warmupIterations; index += 1) {
|
||||
if (aborted(options.signal)) {
|
||||
return partial("cancelled", "Benchmark cancelled during warm-up.");
|
||||
}
|
||||
if (clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
progress(options, {
|
||||
phase: "warming-up",
|
||||
completed: index,
|
||||
total: request.settings.warmupIterations,
|
||||
message: `Warm-up ${index + 1} of ${request.settings.warmupIterations}…`,
|
||||
});
|
||||
const sample = await client.benchmarkSample(
|
||||
request,
|
||||
nextRequestTimeout(),
|
||||
);
|
||||
if (!sample.accepted) {
|
||||
return partial(
|
||||
"error",
|
||||
sample.compileError ??
|
||||
"The native ECMAScript engine rejected a warm-up sample.",
|
||||
);
|
||||
}
|
||||
completedWarmups += 1;
|
||||
}
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < request.settings.measuredIterations;
|
||||
index += 1
|
||||
) {
|
||||
if (aborted(options.signal)) {
|
||||
return partial("cancelled", "Benchmark cancelled while measuring.");
|
||||
}
|
||||
if (clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
progress(options, {
|
||||
phase: "measuring",
|
||||
completed: index,
|
||||
total: request.settings.measuredIterations,
|
||||
message: `Measured sample ${index + 1} of ${request.settings.measuredIterations}…`,
|
||||
});
|
||||
const sample = await client.benchmarkSample(
|
||||
request,
|
||||
nextRequestTimeout(),
|
||||
);
|
||||
if (!sample.accepted) {
|
||||
return partial(
|
||||
"error",
|
||||
sample.compileError ??
|
||||
"The native ECMAScript engine rejected a measured sample.",
|
||||
);
|
||||
}
|
||||
warmSamples.push(sample);
|
||||
}
|
||||
} catch (error) {
|
||||
const kind = errorKind(error);
|
||||
if (kind === "timeout") {
|
||||
if (requestWasWallLimited || clock.now() >= deadline) {
|
||||
return partial(
|
||||
"wall-time-limit",
|
||||
"Benchmark stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
return partial(
|
||||
"timeout",
|
||||
"A benchmark sample timed out; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
if (kind === "cancelled" || aborted(options.signal)) {
|
||||
return partial(
|
||||
"cancelled",
|
||||
"Benchmark cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
return partial("error", `Benchmark worker failed: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
for (const sample of [coldSample, ...warmSamples]) {
|
||||
if (sample?.matchCollectionTruncated) {
|
||||
warnings.push(
|
||||
`Match collection reached the ${request.maximumMatches.toLocaleString()}-match benchmark limit.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const skippedReplacement = [coldSample, ...warmSamples].find(
|
||||
(sample) => sample?.replacementSkippedReason,
|
||||
)?.replacementSkippedReason;
|
||||
if (skippedReplacement) {
|
||||
warnings.push(`Replacement timing skipped: ${skippedReplacement}`);
|
||||
}
|
||||
return benchmarkResult(request, started, clock, {
|
||||
status: "complete",
|
||||
identity,
|
||||
coldStartMs,
|
||||
coldSample,
|
||||
warmSamples,
|
||||
completedWarmups,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
function generatedSubjectSize(
|
||||
request: GrowthAnalysisRequest,
|
||||
repetitions: number,
|
||||
): { readonly utf16: number; readonly bytes: number } {
|
||||
return {
|
||||
utf16:
|
||||
request.settings.prefix.length +
|
||||
request.settings.repeatedFragment.length * repetitions +
|
||||
request.settings.suffix.length,
|
||||
bytes:
|
||||
utf8ByteLength(request.settings.prefix) +
|
||||
utf8ByteLength(request.settings.repeatedFragment) * repetitions +
|
||||
utf8ByteLength(request.settings.suffix),
|
||||
};
|
||||
}
|
||||
|
||||
function dynamicFinding(
|
||||
request: GrowthAnalysisRequest,
|
||||
rule: "observed-growth" | "observed-timeout",
|
||||
details: {
|
||||
readonly confidence: "medium" | "high";
|
||||
readonly severity: "warning" | "high";
|
||||
readonly explanation: string;
|
||||
readonly exampleRisk: string;
|
||||
},
|
||||
): RegexRiskFinding {
|
||||
return {
|
||||
id: `risk-${rule}-0-${request.pattern.length}`,
|
||||
flavour: "ecmascript",
|
||||
range: { startUtf16: 0, endUtf16: request.pattern.length },
|
||||
rule,
|
||||
title:
|
||||
rule === "observed-timeout"
|
||||
? "Observed timeout under selected limits"
|
||||
: "Observed disproportionate growth",
|
||||
explanation: details.explanation,
|
||||
exampleRisk: details.exampleRisk,
|
||||
confidence: details.confidence,
|
||||
severity: details.severity,
|
||||
limitations:
|
||||
"This observation applies only to the generated subjects, browser runtime and limits shown in this result.",
|
||||
suggestedInvestigation:
|
||||
"Repeat with representative production boundaries, retain strict worker limits, and simplify ambiguous repetition where possible.",
|
||||
evidence: "dynamically-observed",
|
||||
};
|
||||
}
|
||||
|
||||
function growthResult(
|
||||
request: GrowthAnalysisRequest,
|
||||
started: number,
|
||||
clock: Clock,
|
||||
values: {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: GrowthAnalysisResult["identity"];
|
||||
readonly samples: readonly GrowthSample[];
|
||||
readonly stopReason: GrowthStopReason;
|
||||
readonly stoppedReason: string;
|
||||
readonly dynamicFindings: readonly RegexRiskFinding[];
|
||||
},
|
||||
): GrowthAnalysisResult {
|
||||
return {
|
||||
status: values.status,
|
||||
...(values.identity ? { identity: values.identity } : {}),
|
||||
samples: values.samples,
|
||||
stopReason: values.stopReason,
|
||||
stoppedReason: values.stoppedReason,
|
||||
wallTimeMs: Math.max(0, clock.now() - started),
|
||||
dynamicFindings: values.dynamicFindings,
|
||||
settings: request.settings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runGrowthAnalysis(
|
||||
request: GrowthAnalysisRequest,
|
||||
client: AnalysisWorkerClient,
|
||||
options: AnalysisRunOptions = {},
|
||||
clock: Clock = SYSTEM_CLOCK,
|
||||
): Promise<GrowthAnalysisResult> {
|
||||
validateGrowthRequest(request);
|
||||
const started = clock.now();
|
||||
const deadline = started + request.settings.maximumWallTimeMs;
|
||||
const samples: GrowthSample[] = [];
|
||||
const dynamicFindings: RegexRiskFinding[] = [];
|
||||
let identity: GrowthAnalysisResult["identity"];
|
||||
let currentRepetitions = request.settings.startingRepetitions;
|
||||
let requestWasWallLimited = false;
|
||||
const nextRequestTimeout = () => {
|
||||
requestWasWallLimited =
|
||||
deadline - clock.now() < request.settings.sampleTimeoutMs;
|
||||
return timeoutWithinDeadline(
|
||||
request.settings.sampleTimeoutMs,
|
||||
deadline,
|
||||
clock,
|
||||
);
|
||||
};
|
||||
|
||||
const finish = (
|
||||
status: AnalysisRunStatus,
|
||||
stopReason: GrowthStopReason,
|
||||
stoppedReason: string,
|
||||
) =>
|
||||
growthResult(request, started, clock, {
|
||||
status,
|
||||
identity,
|
||||
samples,
|
||||
stopReason,
|
||||
stoppedReason,
|
||||
dynamicFindings,
|
||||
});
|
||||
|
||||
try {
|
||||
if (aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled before it started.",
|
||||
);
|
||||
}
|
||||
identity = await client.identity(nextRequestTimeout());
|
||||
|
||||
while (samples.length < request.settings.maximumSteps) {
|
||||
if (aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
if (clock.now() >= deadline) {
|
||||
return finish(
|
||||
"wall-time-limit",
|
||||
"wall-time-limit",
|
||||
"Growth analysis stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
if (currentRepetitions > request.settings.maximumRepetitions) {
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-repetitions",
|
||||
"Growth analysis reached the configured repetition bound.",
|
||||
);
|
||||
}
|
||||
const size = generatedSubjectSize(request, currentRepetitions);
|
||||
if (
|
||||
!Number.isSafeInteger(size.bytes) ||
|
||||
!Number.isSafeInteger(size.utf16) ||
|
||||
size.bytes > request.settings.maximumSubjectBytes
|
||||
) {
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-subject-size",
|
||||
"Growth analysis stopped before exceeding the generated-subject byte limit.",
|
||||
);
|
||||
}
|
||||
|
||||
progress(options, {
|
||||
phase: "growth-probe",
|
||||
completed: samples.length,
|
||||
total: request.settings.maximumSteps,
|
||||
message: `Probing ${currentRepetitions.toLocaleString()} fragment repetitions…`,
|
||||
});
|
||||
const subject =
|
||||
request.settings.prefix +
|
||||
request.settings.repeatedFragment.repeat(currentRepetitions) +
|
||||
request.settings.suffix;
|
||||
let workerSample;
|
||||
try {
|
||||
workerSample = await client.growthProbe(
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
pattern: request.pattern,
|
||||
flags: request.flags,
|
||||
subject,
|
||||
scanAll: request.scanAll,
|
||||
maximumMatches: request.maximumMatches,
|
||||
},
|
||||
nextRequestTimeout(),
|
||||
);
|
||||
} catch (error) {
|
||||
const kind = errorKind(error);
|
||||
if (kind === "timeout") {
|
||||
if (requestWasWallLimited || clock.now() >= deadline) {
|
||||
return finish(
|
||||
"wall-time-limit",
|
||||
"wall-time-limit",
|
||||
"Growth analysis stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "timeout",
|
||||
message: "Worker timed out and was terminated.",
|
||||
});
|
||||
dynamicFindings.push(
|
||||
dynamicFinding(request, "observed-timeout", {
|
||||
confidence: "high",
|
||||
severity: "high",
|
||||
explanation: `Execution did not finish within ${request.settings.sampleTimeoutMs.toLocaleString()} ms at ${currentRepetitions.toLocaleString()} generated repetitions.`,
|
||||
exampleRisk:
|
||||
"The selected generated near-miss exhausted the configured per-sample wall time.",
|
||||
}),
|
||||
);
|
||||
return finish(
|
||||
"timeout",
|
||||
"timeout",
|
||||
"Observed timeout under selected limits; the worker was terminated.",
|
||||
);
|
||||
}
|
||||
if (kind === "cancelled" || aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "crash",
|
||||
message: errorMessage(error),
|
||||
});
|
||||
return finish(
|
||||
"error",
|
||||
"crash",
|
||||
`Growth worker failed: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!workerSample.accepted) {
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "compile-error",
|
||||
message: workerSample.compileError,
|
||||
});
|
||||
return finish(
|
||||
"error",
|
||||
"compile-error",
|
||||
workerSample.compileError ??
|
||||
"The native ECMAScript engine rejected the pattern.",
|
||||
);
|
||||
}
|
||||
|
||||
const previous = samples.at(-1);
|
||||
const executionMs = workerSample.executionMs ?? 0;
|
||||
let normalizedGrowth: number | undefined;
|
||||
if (
|
||||
previous?.status === "complete" &&
|
||||
previous.executionMs !== undefined &&
|
||||
previous.executionMs >= 0.25 &&
|
||||
executionMs >= 1 &&
|
||||
previous.inputBytes > 0
|
||||
) {
|
||||
const inputRatio = size.bytes / previous.inputBytes;
|
||||
const timeRatio = executionMs / previous.executionMs;
|
||||
if (inputRatio > 0) normalizedGrowth = timeRatio / inputRatio;
|
||||
}
|
||||
samples.push({
|
||||
repetitions: currentRepetitions,
|
||||
inputUtf16: size.utf16,
|
||||
inputBytes: size.bytes,
|
||||
status: "complete",
|
||||
executionMs,
|
||||
matched: workerSample.matched,
|
||||
matchCount: workerSample.matchCount,
|
||||
matchCollectionTruncated: workerSample.matchCollectionTruncated,
|
||||
...(normalizedGrowth === undefined ? {} : { normalizedGrowth }),
|
||||
});
|
||||
|
||||
if (
|
||||
normalizedGrowth !== undefined &&
|
||||
normalizedGrowth >= request.settings.normalizedGrowthThreshold
|
||||
) {
|
||||
dynamicFindings.push(
|
||||
dynamicFinding(request, "observed-growth", {
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
explanation: `Observed time growth was ${normalizedGrowth.toFixed(2)}× the input growth between the last two generated samples.`,
|
||||
exampleRisk:
|
||||
"Execution time increased disproportionately for the selected generated subject family.",
|
||||
}),
|
||||
);
|
||||
return finish(
|
||||
"complete",
|
||||
"growth-threshold",
|
||||
"Growth analysis stopped at the configured normalized-growth threshold.",
|
||||
);
|
||||
}
|
||||
if (currentRepetitions >= request.settings.maximumRepetitions) {
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-repetitions",
|
||||
"Growth analysis reached the configured repetition bound.",
|
||||
);
|
||||
}
|
||||
const next = Math.min(
|
||||
request.settings.maximumRepetitions,
|
||||
Math.max(
|
||||
currentRepetitions + 1,
|
||||
Math.ceil(currentRepetitions * request.settings.multiplier),
|
||||
),
|
||||
);
|
||||
currentRepetitions = next;
|
||||
}
|
||||
} catch (error) {
|
||||
const kind = errorKind(error);
|
||||
if (kind === "timeout") {
|
||||
if (requestWasWallLimited || clock.now() >= deadline) {
|
||||
return finish(
|
||||
"wall-time-limit",
|
||||
"wall-time-limit",
|
||||
"Growth analysis stopped at its aggregate wall-time limit.",
|
||||
);
|
||||
}
|
||||
return finish(
|
||||
"timeout",
|
||||
"timeout",
|
||||
"The analysis worker timed out while starting and was terminated.",
|
||||
);
|
||||
}
|
||||
if (kind === "cancelled" || aborted(options.signal)) {
|
||||
return finish(
|
||||
"cancelled",
|
||||
"cancelled",
|
||||
"Growth analysis cancelled; its worker was terminated.",
|
||||
);
|
||||
}
|
||||
return finish(
|
||||
"error",
|
||||
"crash",
|
||||
`Growth worker failed: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return finish(
|
||||
"complete",
|
||||
"maximum-steps",
|
||||
"Growth analysis reached the configured step limit.",
|
||||
);
|
||||
}
|
||||
266
src/regex/analysis/analysis.types.ts
Normal file
266
src/regex/analysis/analysis.types.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { RegexFlavourId } from "../model/flavour";
|
||||
import type { NormalizedRegexNode, SourceRange } from "../model/syntax";
|
||||
|
||||
export type RegexRiskConfidence = "low" | "medium" | "high";
|
||||
|
||||
export type RegexRiskSeverity = "notice" | "warning" | "high";
|
||||
|
||||
export type RegexRiskEvidence = "static" | "dynamically-observed";
|
||||
|
||||
export type RegexRiskRule =
|
||||
| "nested-quantifier"
|
||||
| "ambiguous-repeated-alternatives"
|
||||
| "overlapping-alternatives"
|
||||
| "repeated-wildcard"
|
||||
| "backreference-in-repetition"
|
||||
| "nullable-repeated-expression"
|
||||
| "repeated-lookaround-body"
|
||||
| "unanchored-expensive-prefix"
|
||||
| "excessive-captures"
|
||||
| "excessive-nesting"
|
||||
| "potential-replacement-expansion"
|
||||
| "observed-growth"
|
||||
| "observed-timeout";
|
||||
|
||||
export interface RegexRiskFinding {
|
||||
readonly id: string;
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly range: SourceRange;
|
||||
readonly rule: RegexRiskRule;
|
||||
readonly title: string;
|
||||
readonly explanation: string;
|
||||
readonly exampleRisk: string;
|
||||
readonly confidence: RegexRiskConfidence;
|
||||
readonly severity: RegexRiskSeverity;
|
||||
readonly limitations: string;
|
||||
readonly suggestedInvestigation: string;
|
||||
readonly evidence: RegexRiskEvidence;
|
||||
}
|
||||
|
||||
export interface StaticRiskRequest {
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly root: NormalizedRegexNode;
|
||||
readonly flags: readonly string[];
|
||||
readonly scanAll: boolean;
|
||||
readonly replacement?: string;
|
||||
}
|
||||
|
||||
export interface StaticRiskReport {
|
||||
readonly flavour: RegexFlavourId;
|
||||
readonly analyser: {
|
||||
readonly id: "regex-tools-ecmascript-heuristics";
|
||||
readonly version: "1";
|
||||
readonly support: "experimental-ecmascript-2025" | "unsupported-flavour";
|
||||
};
|
||||
readonly findings: readonly RegexRiskFinding[];
|
||||
readonly analysedNodes: number;
|
||||
readonly findingsTruncated: boolean;
|
||||
readonly traversalTruncated: boolean;
|
||||
readonly summary: string;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface AnalysisEngineIdentity {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly engineName: "Native ECMAScript RegExp";
|
||||
readonly engineVersion: string;
|
||||
readonly runtimeVersion: string;
|
||||
readonly nativeOffsetUnit: "utf16";
|
||||
}
|
||||
|
||||
export interface BenchmarkSubject {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly replacement: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly maximumMatches: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkWorkerSample {
|
||||
readonly accepted: boolean;
|
||||
readonly compileError?: string;
|
||||
readonly effectiveFlags: string;
|
||||
readonly subjectBytes: number;
|
||||
readonly subjectUtf16: number;
|
||||
readonly compileMs: number;
|
||||
readonly firstMatchMs?: number;
|
||||
readonly allMatchesMs?: number;
|
||||
readonly replacementMs?: number;
|
||||
readonly replacementSkippedReason?: string;
|
||||
readonly throughputBytesPerSecond?: number;
|
||||
readonly matchCount: number;
|
||||
readonly matched: boolean;
|
||||
readonly matchCollectionTruncated: boolean;
|
||||
readonly replacementOutputUtf16?: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkSettings {
|
||||
readonly warmupIterations: number;
|
||||
readonly measuredIterations: number;
|
||||
readonly sampleTimeoutMs: number;
|
||||
readonly maximumWallTimeMs: number;
|
||||
}
|
||||
|
||||
export interface RegexBenchmarkRequest extends BenchmarkSubject {
|
||||
readonly settings: BenchmarkSettings;
|
||||
}
|
||||
|
||||
export interface MetricStatistics {
|
||||
readonly count: number;
|
||||
readonly minimum: number;
|
||||
readonly median: number;
|
||||
readonly p95: number;
|
||||
readonly maximum: number;
|
||||
}
|
||||
|
||||
export interface BenchmarkMetricSummary {
|
||||
readonly compileMs?: MetricStatistics;
|
||||
readonly firstMatchMs?: MetricStatistics;
|
||||
readonly allMatchesMs?: MetricStatistics;
|
||||
readonly replacementMs?: MetricStatistics;
|
||||
readonly throughputBytesPerSecond?: MetricStatistics;
|
||||
}
|
||||
|
||||
export type AnalysisRunStatus =
|
||||
"complete" | "timeout" | "cancelled" | "wall-time-limit" | "error";
|
||||
|
||||
export interface RegexBenchmarkResult {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: AnalysisEngineIdentity;
|
||||
readonly coldStartMs?: number;
|
||||
readonly coldSample?: BenchmarkWorkerSample;
|
||||
readonly warmSamples: readonly BenchmarkWorkerSample[];
|
||||
readonly warmStatistics: BenchmarkMetricSummary;
|
||||
readonly completedWarmups: number;
|
||||
readonly completedMeasuredIterations: number;
|
||||
readonly requestedSettings: BenchmarkSettings;
|
||||
readonly wallTimeMs: number;
|
||||
readonly stoppedReason?: string;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface GrowthProbeSubject {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly maximumMatches: number;
|
||||
}
|
||||
|
||||
export interface GrowthWorkerSample {
|
||||
readonly accepted: boolean;
|
||||
readonly compileError?: string;
|
||||
readonly effectiveFlags: string;
|
||||
readonly subjectBytes: number;
|
||||
readonly subjectUtf16: number;
|
||||
readonly executionMs?: number;
|
||||
readonly matchCount: number;
|
||||
readonly matched: boolean;
|
||||
readonly matchCollectionTruncated: boolean;
|
||||
}
|
||||
|
||||
export interface GrowthSettings {
|
||||
readonly prefix: string;
|
||||
readonly repeatedFragment: string;
|
||||
readonly suffix: string;
|
||||
readonly startingRepetitions: number;
|
||||
readonly maximumRepetitions: number;
|
||||
readonly multiplier: number;
|
||||
readonly maximumSteps: number;
|
||||
readonly sampleTimeoutMs: number;
|
||||
readonly maximumWallTimeMs: number;
|
||||
readonly maximumSubjectBytes: number;
|
||||
readonly normalizedGrowthThreshold: number;
|
||||
}
|
||||
|
||||
export type GrowthSampleStatus =
|
||||
"complete" | "timeout" | "crash" | "compile-error";
|
||||
|
||||
export interface GrowthSample {
|
||||
readonly repetitions: number;
|
||||
readonly inputUtf16: number;
|
||||
readonly inputBytes: number;
|
||||
readonly status: GrowthSampleStatus;
|
||||
readonly executionMs?: number;
|
||||
readonly matched?: boolean;
|
||||
readonly matchCount?: number;
|
||||
readonly matchCollectionTruncated?: boolean;
|
||||
readonly normalizedGrowth?: number;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
export type GrowthStopReason =
|
||||
| "maximum-repetitions"
|
||||
| "maximum-steps"
|
||||
| "maximum-subject-size"
|
||||
| "growth-threshold"
|
||||
| "timeout"
|
||||
| "compile-error"
|
||||
| "crash"
|
||||
| "cancelled"
|
||||
| "wall-time-limit";
|
||||
|
||||
export interface GrowthAnalysisRequest {
|
||||
readonly flavour: "ecmascript";
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly scanAll: boolean;
|
||||
readonly maximumMatches: number;
|
||||
readonly settings: GrowthSettings;
|
||||
}
|
||||
|
||||
export interface GrowthAnalysisResult {
|
||||
readonly status: AnalysisRunStatus;
|
||||
readonly identity?: AnalysisEngineIdentity;
|
||||
readonly samples: readonly GrowthSample[];
|
||||
readonly stopReason: GrowthStopReason;
|
||||
readonly stoppedReason: string;
|
||||
readonly wallTimeMs: number;
|
||||
readonly dynamicFindings: readonly RegexRiskFinding[];
|
||||
readonly settings: GrowthSettings;
|
||||
}
|
||||
|
||||
export type AnalysisWorkerOperation =
|
||||
| { readonly kind: "identity" }
|
||||
| {
|
||||
readonly kind: "benchmark-sample";
|
||||
readonly request: BenchmarkSubject;
|
||||
}
|
||||
| {
|
||||
readonly kind: "growth-probe";
|
||||
readonly request: GrowthProbeSubject;
|
||||
};
|
||||
|
||||
export type AnalysisWorkerResult =
|
||||
| {
|
||||
readonly kind: "identity";
|
||||
readonly result: AnalysisEngineIdentity;
|
||||
}
|
||||
| {
|
||||
readonly kind: "benchmark-sample";
|
||||
readonly result: BenchmarkWorkerSample;
|
||||
}
|
||||
| {
|
||||
readonly kind: "growth-probe";
|
||||
readonly result: GrowthWorkerSample;
|
||||
};
|
||||
|
||||
export interface AnalysisProgress {
|
||||
readonly phase:
|
||||
| "starting-worker"
|
||||
| "cold-sample"
|
||||
| "warming-up"
|
||||
| "measuring"
|
||||
| "growth-probe";
|
||||
readonly completed: number;
|
||||
readonly total: number;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface AnalysisRunOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly onProgress?: (progress: AnalysisProgress) => void;
|
||||
}
|
||||
180
src/regex/analysis/ecmascript-analysis.test.ts
Normal file
180
src/regex/analysis/ecmascript-analysis.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
benchmarkEcmaScriptSample,
|
||||
ecmaScriptAnalysisIdentity,
|
||||
probeEcmaScriptGrowth,
|
||||
} from "./ecmascript-analysis";
|
||||
|
||||
function benchmarkRequest(
|
||||
overrides: Partial<Parameters<typeof benchmarkEcmaScriptSample>[0]> = {},
|
||||
) {
|
||||
return {
|
||||
flavour: "ecmascript" as const,
|
||||
pattern: "\\d+",
|
||||
flags: ["g"],
|
||||
subject: "12 345",
|
||||
replacement: "[$&]",
|
||||
scanAll: true,
|
||||
maximumMatches: 10_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("native ECMAScript analysis worker primitives", () => {
|
||||
it("retains exact runtime identity and separates benchmark phases", () => {
|
||||
const identity = ecmaScriptAnalysisIdentity();
|
||||
const sample = benchmarkEcmaScriptSample(benchmarkRequest());
|
||||
|
||||
expect(identity).toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
nativeOffsetUnit: "utf16",
|
||||
}),
|
||||
);
|
||||
expect(sample).toEqual(
|
||||
expect.objectContaining({
|
||||
accepted: true,
|
||||
effectiveFlags: expect.stringContaining("g"),
|
||||
matchCount: 2,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 10,
|
||||
}),
|
||||
);
|
||||
expect(sample.compileMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.firstMatchMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.allMatchesMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.replacementMs).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.throughputBytesPerSecond).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("bounds zero-length global iteration", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "(?=.)",
|
||||
flags: ["g", "u"],
|
||||
subject: "😀x",
|
||||
replacement: "",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sample.matchCount).toBe(2);
|
||||
expect(sample.matchCollectionTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves single sticky execution unless scan-all is explicit", () => {
|
||||
const single = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["y"],
|
||||
subject: "aa",
|
||||
replacement: "x",
|
||||
scanAll: false,
|
||||
}),
|
||||
);
|
||||
const scanAll = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["y"],
|
||||
subject: "aa",
|
||||
replacement: "x",
|
||||
scanAll: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(single.matchCount).toBe(1);
|
||||
expect(scanAll.matchCount).toBe(2);
|
||||
expect(scanAll.replacementMs).toBeUndefined();
|
||||
expect(scanAll.replacementSkippedReason).toContain(
|
||||
"multi-match sticky replacement",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compile rejection as data", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({ pattern: "(", subject: "" }),
|
||||
);
|
||||
const growth = probeEcmaScriptGrowth({
|
||||
flavour: "ecmascript",
|
||||
pattern: "(",
|
||||
flags: [],
|
||||
subject: "",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
});
|
||||
|
||||
expect(sample.accepted).toBe(false);
|
||||
expect(sample.compileError).toBeTruthy();
|
||||
expect(growth).toEqual(
|
||||
expect.objectContaining({
|
||||
accepted: false,
|
||||
compileError: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips replacement timing before a conservative output can grow beyond its bound", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "a".repeat(4_096),
|
||||
replacement: "$`$'",
|
||||
maximumMatches: 10_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sample.replacementMs).toBeUndefined();
|
||||
expect(sample.replacementSkippedReason).toContain(
|
||||
"Conservative output estimate",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not run unbounded replacement work after match collection truncates", () => {
|
||||
const sample = benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "aaaa",
|
||||
maximumMatches: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sample.matchCollectionTruncated).toBe(true);
|
||||
expect(sample.replacementMs).toBeUndefined();
|
||||
expect(sample.replacementSkippedReason).toContain(
|
||||
"Match collection reached its bound",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects flags outside the supported ECMAScript set", () => {
|
||||
expect(() =>
|
||||
benchmarkEcmaScriptSample(
|
||||
benchmarkRequest({ flags: ["g", "unsupported"] }),
|
||||
),
|
||||
).toThrow(/Unsupported ECMAScript analysis flag/u);
|
||||
});
|
||||
|
||||
it("reports bounded growth probes without materializing match values", () => {
|
||||
const sample = probeEcmaScriptGrowth({
|
||||
flavour: "ecmascript",
|
||||
pattern: "a+$",
|
||||
flags: [],
|
||||
subject: "aaaa!",
|
||||
scanAll: false,
|
||||
maximumMatches: 10,
|
||||
});
|
||||
|
||||
expect(sample).toEqual(
|
||||
expect.objectContaining({
|
||||
accepted: true,
|
||||
subjectBytes: 5,
|
||||
subjectUtf16: 5,
|
||||
matched: false,
|
||||
matchCount: 0,
|
||||
}),
|
||||
);
|
||||
expect(sample.executionMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
299
src/regex/analysis/ecmascript-analysis.ts
Normal file
299
src/regex/analysis/ecmascript-analysis.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { utf8ByteLength } from "../execution/request-limits";
|
||||
import { ANALYSIS_LIMITS } from "./analysis-limits";
|
||||
import type {
|
||||
AnalysisEngineIdentity,
|
||||
BenchmarkSubject,
|
||||
BenchmarkWorkerSample,
|
||||
GrowthProbeSubject,
|
||||
GrowthWorkerSample,
|
||||
} from "./analysis.types";
|
||||
|
||||
const FLAG_ORDER = "dgimsuvy";
|
||||
|
||||
function runtimeIdentity(): string {
|
||||
return typeof navigator === "undefined"
|
||||
? "Unknown ECMAScript runtime"
|
||||
: navigator.userAgent;
|
||||
}
|
||||
|
||||
export function ecmaScriptAnalysisIdentity(): AnalysisEngineIdentity {
|
||||
const identity = runtimeIdentity();
|
||||
return {
|
||||
flavour: "ecmascript",
|
||||
engineName: "Native ECMAScript RegExp",
|
||||
engineVersion: identity,
|
||||
runtimeVersion: identity,
|
||||
nativeOffsetUnit: "utf16",
|
||||
};
|
||||
}
|
||||
|
||||
function hasIndicesSupport(): boolean {
|
||||
try {
|
||||
return new RegExp("", "d").hasIndices;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveFlags(flags: readonly string[], scanAll: boolean): string {
|
||||
for (const flag of flags) {
|
||||
if (!FLAG_ORDER.includes(flag)) {
|
||||
throw new RangeError(
|
||||
`Unsupported ECMAScript analysis flag ${JSON.stringify(flag)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const unique = new Set(flags);
|
||||
if (hasIndicesSupport()) unique.add("d");
|
||||
if (scanAll && !unique.has("g") && !unique.has("y")) unique.add("g");
|
||||
return FLAG_ORDER.split("")
|
||||
.filter((flag) => unique.has(flag))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function advanceStringIndex(
|
||||
subject: string,
|
||||
index: number,
|
||||
unicode: boolean,
|
||||
): number {
|
||||
if (!unicode) return index + 1;
|
||||
const first = subject.charCodeAt(index);
|
||||
if (first < 0xd800 || first > 0xdbff || index + 1 >= subject.length) {
|
||||
return index + 1;
|
||||
}
|
||||
const second = subject.charCodeAt(index + 1);
|
||||
return second >= 0xdc00 && second <= 0xdfff ? index + 2 : index + 1;
|
||||
}
|
||||
|
||||
function collectMatches(
|
||||
expression: RegExp,
|
||||
subject: string,
|
||||
iterate: boolean,
|
||||
maximumMatches: number,
|
||||
): {
|
||||
readonly count: number;
|
||||
readonly matched: boolean;
|
||||
readonly truncated: boolean;
|
||||
} {
|
||||
const unicode = expression.unicode || expression.flags.includes("v");
|
||||
expression.lastIndex = 0;
|
||||
let count = 0;
|
||||
while (true) {
|
||||
const match = expression.exec(subject);
|
||||
if (!match) break;
|
||||
count += 1;
|
||||
if (count >= maximumMatches) {
|
||||
return { count, matched: true, truncated: iterate };
|
||||
}
|
||||
if (!iterate) break;
|
||||
if (match[0].length === 0) {
|
||||
expression.lastIndex = advanceStringIndex(
|
||||
subject,
|
||||
expression.lastIndex,
|
||||
unicode,
|
||||
);
|
||||
if (expression.lastIndex > subject.length) break;
|
||||
}
|
||||
}
|
||||
return { count, matched: count > 0, truncated: false };
|
||||
}
|
||||
|
||||
function assertWorkerInput(
|
||||
request: BenchmarkSubject | GrowthProbeSubject,
|
||||
): void {
|
||||
if (request.flavour !== "ecmascript") {
|
||||
throw new RangeError("The analysis worker supports ECMAScript only.");
|
||||
}
|
||||
if (request.pattern.length > 64 * 1024) {
|
||||
throw new RangeError("Pattern exceeds the 64 Ki UTF-16 analysis limit.");
|
||||
}
|
||||
if (utf8ByteLength(request.subject) > 16 * 1024 * 1024) {
|
||||
throw new RangeError("Subject exceeds the 16 MiB analysis limit.");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(request.maximumMatches) ||
|
||||
request.maximumMatches < 1 ||
|
||||
request.maximumMatches > 10_000
|
||||
) {
|
||||
throw new RangeError("Analysis match limit must be between 1 and 10,000.");
|
||||
}
|
||||
}
|
||||
|
||||
function replacementWorstCaseUtf16(
|
||||
replacement: string,
|
||||
subjectUtf16: number,
|
||||
matchCount: number,
|
||||
): number {
|
||||
let references = 0;
|
||||
for (let index = 0; index < replacement.length - 1; index += 1) {
|
||||
if (
|
||||
replacement[index] === "$" &&
|
||||
/[`'&0-9<]/u.test(replacement[index + 1] ?? "")
|
||||
) {
|
||||
references += 1;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
const literal = replacement.length;
|
||||
return (
|
||||
subjectUtf16 +
|
||||
matchCount * (literal + references * Math.max(1, subjectUtf16))
|
||||
);
|
||||
}
|
||||
|
||||
function compileFailure(
|
||||
effective: string,
|
||||
subject: string,
|
||||
compileMs: number,
|
||||
error: unknown,
|
||||
): BenchmarkWorkerSample {
|
||||
return {
|
||||
accepted: false,
|
||||
compileError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The native ECMAScript engine rejected the pattern.",
|
||||
effectiveFlags: effective,
|
||||
subjectBytes: utf8ByteLength(subject),
|
||||
subjectUtf16: subject.length,
|
||||
compileMs,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function benchmarkEcmaScriptSample(
|
||||
request: BenchmarkSubject,
|
||||
): BenchmarkWorkerSample {
|
||||
assertWorkerInput(request);
|
||||
if (request.replacement.length > 64 * 1024) {
|
||||
throw new RangeError(
|
||||
"Replacement exceeds the 64 Ki UTF-16 analysis limit.",
|
||||
);
|
||||
}
|
||||
const flags = effectiveFlags(request.flags, request.scanAll);
|
||||
const compileStart = performance.now();
|
||||
let compiled: RegExp;
|
||||
try {
|
||||
compiled = new RegExp(request.pattern, flags);
|
||||
} catch (error) {
|
||||
return compileFailure(
|
||||
flags,
|
||||
request.subject,
|
||||
performance.now() - compileStart,
|
||||
error,
|
||||
);
|
||||
}
|
||||
const compileMs = performance.now() - compileStart;
|
||||
const iterate = flags.includes("g") || request.scanAll;
|
||||
|
||||
const firstStart = performance.now();
|
||||
compiled.exec(request.subject);
|
||||
const firstMatchMs = performance.now() - firstStart;
|
||||
|
||||
const allExpression = new RegExp(request.pattern, flags);
|
||||
const allStart = performance.now();
|
||||
const collection = collectMatches(
|
||||
allExpression,
|
||||
request.subject,
|
||||
iterate,
|
||||
request.maximumMatches,
|
||||
);
|
||||
const allMatchesMs = performance.now() - allStart;
|
||||
const subjectBytes = utf8ByteLength(request.subject);
|
||||
const throughputBytesPerSecond =
|
||||
allMatchesMs > 0 ? (subjectBytes / allMatchesMs) * 1_000 : undefined;
|
||||
|
||||
const worstCaseOutput = replacementWorstCaseUtf16(
|
||||
request.replacement,
|
||||
request.subject.length,
|
||||
collection.count,
|
||||
);
|
||||
let replacementMs: number | undefined;
|
||||
let replacementOutputUtf16: number | undefined;
|
||||
let replacementSkippedReason: string | undefined;
|
||||
if (flags.includes("y") && request.scanAll) {
|
||||
replacementSkippedReason =
|
||||
"Explicit multi-match sticky replacement is not timed with native String.replace because that API replaces only one sticky match.";
|
||||
} else if (collection.truncated) {
|
||||
replacementSkippedReason =
|
||||
"Match collection reached its bound, so total replacement work and output are not known.";
|
||||
} else if (
|
||||
worstCaseOutput > ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16
|
||||
) {
|
||||
replacementSkippedReason = `Conservative output estimate exceeds ${ANALYSIS_LIMITS.maximumReplacementBenchmarkOutputUtf16.toLocaleString()} UTF-16 units.`;
|
||||
} else {
|
||||
const replacementExpression = new RegExp(request.pattern, flags);
|
||||
const replacementStart = performance.now();
|
||||
const output = request.subject.replace(
|
||||
replacementExpression,
|
||||
request.replacement,
|
||||
);
|
||||
replacementMs = performance.now() - replacementStart;
|
||||
replacementOutputUtf16 = output.length;
|
||||
}
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: flags,
|
||||
subjectBytes,
|
||||
subjectUtf16: request.subject.length,
|
||||
compileMs,
|
||||
firstMatchMs,
|
||||
allMatchesMs,
|
||||
...(replacementMs === undefined ? {} : { replacementMs }),
|
||||
...(replacementSkippedReason ? { replacementSkippedReason } : {}),
|
||||
...(throughputBytesPerSecond === undefined
|
||||
? {}
|
||||
: { throughputBytesPerSecond }),
|
||||
matchCount: collection.count,
|
||||
matched: collection.matched,
|
||||
matchCollectionTruncated: collection.truncated,
|
||||
...(replacementOutputUtf16 === undefined ? {} : { replacementOutputUtf16 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function probeEcmaScriptGrowth(
|
||||
request: GrowthProbeSubject,
|
||||
): GrowthWorkerSample {
|
||||
assertWorkerInput(request);
|
||||
const flags = effectiveFlags(request.flags, request.scanAll);
|
||||
let expression: RegExp;
|
||||
try {
|
||||
expression = new RegExp(request.pattern, flags);
|
||||
} catch (error) {
|
||||
return {
|
||||
accepted: false,
|
||||
compileError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The native ECMAScript engine rejected the pattern.",
|
||||
effectiveFlags: flags,
|
||||
subjectBytes: utf8ByteLength(request.subject),
|
||||
subjectUtf16: request.subject.length,
|
||||
matchCount: 0,
|
||||
matched: false,
|
||||
matchCollectionTruncated: false,
|
||||
};
|
||||
}
|
||||
const iterate = flags.includes("g") || request.scanAll;
|
||||
const started = performance.now();
|
||||
const collection = collectMatches(
|
||||
expression,
|
||||
request.subject,
|
||||
iterate,
|
||||
request.maximumMatches,
|
||||
);
|
||||
const executionMs = performance.now() - started;
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: flags,
|
||||
subjectBytes: utf8ByteLength(request.subject),
|
||||
subjectUtf16: request.subject.length,
|
||||
executionMs,
|
||||
matchCount: collection.count,
|
||||
matched: collection.matched,
|
||||
matchCollectionTruncated: collection.truncated,
|
||||
};
|
||||
}
|
||||
178
src/regex/analysis/static-risk.test.ts
Normal file
178
src/regex/analysis/static-risk.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EcmaScriptSyntaxProvider } from "../syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
|
||||
import type { NormalizedRegexNode } from "../model/syntax";
|
||||
import {
|
||||
analyseStaticRisk,
|
||||
MAXIMUM_STATIC_ANALYSIS_NODES,
|
||||
} from "./static-risk";
|
||||
|
||||
const provider = new EcmaScriptSyntaxProvider();
|
||||
|
||||
async function analyse(
|
||||
pattern: string,
|
||||
overrides: {
|
||||
readonly flags?: readonly string[];
|
||||
readonly scanAll?: boolean;
|
||||
readonly replacement?: string;
|
||||
} = {},
|
||||
) {
|
||||
const flags = overrides.flags ?? [];
|
||||
const syntax = await provider.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "2025",
|
||||
pattern,
|
||||
flags,
|
||||
options: {},
|
||||
});
|
||||
expect(syntax.accepted).toBe(true);
|
||||
return analyseStaticRisk({
|
||||
flavour: "ecmascript",
|
||||
root: syntax.root,
|
||||
flags,
|
||||
scanAll: overrides.scanAll ?? false,
|
||||
replacement: overrides.replacement,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ECMAScript static risk analysis", () => {
|
||||
it("identifies nested, nullable and unanchored repetition without claiming vulnerability", async () => {
|
||||
const report = await analyse("(a*)+$");
|
||||
const rules = report.findings.map((finding) => finding.rule);
|
||||
|
||||
expect(rules).toEqual(
|
||||
expect.arrayContaining([
|
||||
"nested-quantifier",
|
||||
"nullable-repeated-expression",
|
||||
"unanchored-expensive-prefix",
|
||||
]),
|
||||
);
|
||||
expect(report.findings[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "ecmascript",
|
||||
evidence: "static",
|
||||
confidence: expect.stringMatching(/low|medium|high/u),
|
||||
limitations: expect.any(String),
|
||||
suggestedInvestigation: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(report.summary).toMatch(/potential/u);
|
||||
expect(JSON.stringify(report)).not.toMatch(
|
||||
/\bdefinitely vulnerable\b|\bguaranteed linear\b/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects ambiguous alternatives, wildcards and repeated backreferences", async () => {
|
||||
const alternatives = await analyse("(?:a|aa)+$");
|
||||
expect(alternatives.findings.map((finding) => finding.rule)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"ambiguous-repeated-alternatives",
|
||||
"overlapping-alternatives",
|
||||
]),
|
||||
);
|
||||
|
||||
const wildcard = await analyse("(?:.*)+x");
|
||||
expect(wildcard.findings.map((finding) => finding.rule)).toContain(
|
||||
"repeated-wildcard",
|
||||
);
|
||||
|
||||
const backreference = await analyse("(a)(?:\\1+)+$");
|
||||
expect(backreference.findings.map((finding) => finding.rule)).toContain(
|
||||
"backreference-in-repetition",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports repeated lookaround bodies and replacement expansion", async () => {
|
||||
const report = await analyse("(?=(a+)+)a", {
|
||||
flags: ["g"],
|
||||
replacement: "$`$'",
|
||||
});
|
||||
|
||||
expect(report.findings.map((finding) => finding.rule)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"repeated-lookaround-body",
|
||||
"potential-replacement-expansion",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the required cautious empty result wording", async () => {
|
||||
const report = await analyse("^\\d{4}-\\d{2}-\\d{2}$");
|
||||
|
||||
expect(report.findings).toEqual([]);
|
||||
expect(report.summary).toBe(
|
||||
"No issue found by this analyser. This is not a safety guarantee.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply ECMAScript heuristics to another flavour", async () => {
|
||||
const syntax = await provider.parsePattern({
|
||||
flavour: "ecmascript",
|
||||
pattern: "(a+)+",
|
||||
flags: [],
|
||||
options: {},
|
||||
});
|
||||
const report = analyseStaticRisk({
|
||||
flavour: "pcre2",
|
||||
root: syntax.root,
|
||||
flags: [],
|
||||
scanAll: false,
|
||||
});
|
||||
|
||||
expect(report.analyser.support).toBe("unsupported-flavour");
|
||||
expect(report.findings).toEqual([]);
|
||||
expect(report.summary).toContain("unavailable for pcre2");
|
||||
});
|
||||
|
||||
it("bounds normalized-tree traversal independently of render limits", () => {
|
||||
const literal = (index: number): NormalizedRegexNode => ({
|
||||
id: `literal-${index}`,
|
||||
kind: "literal",
|
||||
range: { startUtf16: index, endUtf16: index + 1 },
|
||||
raw: "a",
|
||||
explanation: "literal",
|
||||
children: [],
|
||||
properties: {
|
||||
zeroWidth: false,
|
||||
nullable: false,
|
||||
minimumLength: 1,
|
||||
maximumLength: 1,
|
||||
consumesInput: true,
|
||||
},
|
||||
support: {
|
||||
flavour: "ecmascript",
|
||||
status: "supported",
|
||||
notes: [],
|
||||
},
|
||||
provenance: {
|
||||
provider: "fixture",
|
||||
providerVersion: "1",
|
||||
source: "parsed",
|
||||
},
|
||||
});
|
||||
const root: NormalizedRegexNode = {
|
||||
...literal(0),
|
||||
id: "root",
|
||||
kind: "pattern",
|
||||
range: {
|
||||
startUtf16: 0,
|
||||
endUtf16: MAXIMUM_STATIC_ANALYSIS_NODES + 10,
|
||||
},
|
||||
raw: "a".repeat(MAXIMUM_STATIC_ANALYSIS_NODES + 10),
|
||||
children: Array.from(
|
||||
{ length: MAXIMUM_STATIC_ANALYSIS_NODES + 10 },
|
||||
(_, index) => literal(index),
|
||||
),
|
||||
};
|
||||
|
||||
const report = analyseStaticRisk({
|
||||
flavour: "ecmascript",
|
||||
root,
|
||||
flags: [],
|
||||
scanAll: false,
|
||||
});
|
||||
|
||||
expect(report.analysedNodes).toBe(MAXIMUM_STATIC_ANALYSIS_NODES);
|
||||
expect(report.traversalTruncated).toBe(true);
|
||||
expect(report.limitations.at(-1)).toContain("Traversal stopped");
|
||||
});
|
||||
});
|
||||
573
src/regex/analysis/static-risk.ts
Normal file
573
src/regex/analysis/static-risk.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
import type { RegexFlavourId } from "../model/flavour";
|
||||
import type { NormalizedRegexNode } from "../model/syntax";
|
||||
import type {
|
||||
RegexRiskConfidence,
|
||||
RegexRiskFinding,
|
||||
RegexRiskRule,
|
||||
RegexRiskSeverity,
|
||||
StaticRiskReport,
|
||||
StaticRiskRequest,
|
||||
} from "./analysis.types";
|
||||
|
||||
export const MAXIMUM_STATIC_ANALYSIS_NODES = 20_000;
|
||||
export const MAXIMUM_STATIC_RISK_FINDINGS = 100;
|
||||
const EXCESSIVE_CAPTURE_THRESHOLD = 100;
|
||||
const EXCESSIVE_NESTING_THRESHOLD = 32;
|
||||
const MAXIMUM_FIRST_SIGNATURES = 8;
|
||||
|
||||
type FirstSignature =
|
||||
`literal:${string}` | `class:${string}` | "any" | "unknown";
|
||||
|
||||
interface NodeRecord {
|
||||
readonly node: NormalizedRegexNode;
|
||||
readonly depth: number;
|
||||
readonly parent?: NormalizedRegexNode;
|
||||
}
|
||||
|
||||
interface NodeSummary {
|
||||
readonly containsRepeatableQuantifier: boolean;
|
||||
readonly firstRepeatableQuantifier?: NormalizedRegexNode;
|
||||
readonly firstBackreference?: NormalizedRegexNode;
|
||||
readonly firstWildcard?: NormalizedRegexNode;
|
||||
readonly firstAmbiguousAlternation?: NormalizedRegexNode;
|
||||
readonly firstSignatures: readonly FirstSignature[];
|
||||
}
|
||||
|
||||
interface FindingDetails {
|
||||
readonly node: NormalizedRegexNode;
|
||||
readonly rule: RegexRiskRule;
|
||||
readonly title: string;
|
||||
readonly explanation: string;
|
||||
readonly exampleRisk: string;
|
||||
readonly confidence: RegexRiskConfidence;
|
||||
readonly severity: RegexRiskSeverity;
|
||||
readonly limitations: string;
|
||||
readonly suggestedInvestigation: string;
|
||||
}
|
||||
|
||||
function isRepeatableQuantifier(node: NormalizedRegexNode): boolean {
|
||||
return (
|
||||
node.kind === "quantifier" &&
|
||||
(node.quantifier?.maximum === null || (node.quantifier?.maximum ?? 0) > 1)
|
||||
);
|
||||
}
|
||||
|
||||
function isLookaround(node: NormalizedRegexNode): boolean {
|
||||
return (
|
||||
node.kind === "lookahead" ||
|
||||
node.kind === "negative-lookahead" ||
|
||||
node.kind === "lookbehind" ||
|
||||
node.kind === "negative-lookbehind"
|
||||
);
|
||||
}
|
||||
|
||||
function limitedUnique<T>(values: readonly T[], maximum: number): readonly T[] {
|
||||
return [...new Set(values)].slice(0, maximum);
|
||||
}
|
||||
|
||||
function ownFirstSignature(node: NormalizedRegexNode): FirstSignature[] {
|
||||
if (node.kind === "dot") return ["any"];
|
||||
if (node.kind === "literal") {
|
||||
return [`literal:${node.raw.slice(0, 2)}`];
|
||||
}
|
||||
if (node.kind === "escaped-literal") {
|
||||
if (/^\\[dDsSwWpP]/u.test(node.raw)) {
|
||||
return [`class:${node.raw.slice(0, 2)}`];
|
||||
}
|
||||
return [`literal:${node.raw}`];
|
||||
}
|
||||
if (
|
||||
node.kind === "character-class" ||
|
||||
node.kind === "character-class-range" ||
|
||||
node.kind === "unicode-property"
|
||||
) {
|
||||
return [`class:${node.raw}`];
|
||||
}
|
||||
if (node.kind === "backreference") return ["unknown"];
|
||||
return [];
|
||||
}
|
||||
|
||||
function signaturesOverlap(
|
||||
left: readonly FirstSignature[],
|
||||
right: readonly FirstSignature[],
|
||||
): boolean {
|
||||
for (const leftSignature of left) {
|
||||
for (const rightSignature of right) {
|
||||
if (leftSignature === "unknown" || rightSignature === "unknown") {
|
||||
continue;
|
||||
}
|
||||
if (leftSignature === "any" || rightSignature === "any") return true;
|
||||
if (leftSignature === rightSignature) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasOverlappingChildren(
|
||||
node: NormalizedRegexNode,
|
||||
summaries: ReadonlyMap<NormalizedRegexNode, NodeSummary>,
|
||||
): boolean {
|
||||
const alternativesContainer =
|
||||
node.kind === "disjunction" ||
|
||||
node.kind === "capture-group" ||
|
||||
node.kind === "named-capture-group" ||
|
||||
node.kind === "noncapture-group" ||
|
||||
node.kind === "inline-flags" ||
|
||||
isLookaround(node);
|
||||
if (!alternativesContainer || node.children.length < 2) return false;
|
||||
for (let leftIndex = 0; leftIndex < node.children.length; leftIndex += 1) {
|
||||
const left =
|
||||
summaries.get(node.children[leftIndex]!)?.firstSignatures ?? [];
|
||||
for (
|
||||
let rightIndex = leftIndex + 1;
|
||||
rightIndex < node.children.length;
|
||||
rightIndex += 1
|
||||
) {
|
||||
const right =
|
||||
summaries.get(node.children[rightIndex]!)?.firstSignatures ?? [];
|
||||
if (signaturesOverlap(left, right)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function firstSignaturesFor(
|
||||
node: NormalizedRegexNode,
|
||||
summaries: ReadonlyMap<NormalizedRegexNode, NodeSummary>,
|
||||
): readonly FirstSignature[] {
|
||||
const own = ownFirstSignature(node);
|
||||
if (own.length > 0) return own;
|
||||
if (node.children.length === 0) return [];
|
||||
|
||||
const isSequence = node.kind === "sequence" || node.kind === "alternative";
|
||||
if (isSequence) {
|
||||
const signatures: FirstSignature[] = [];
|
||||
for (const child of node.children) {
|
||||
signatures.push(...(summaries.get(child)?.firstSignatures ?? []));
|
||||
if (child.properties.nullable !== true) break;
|
||||
}
|
||||
return limitedUnique(signatures, MAXIMUM_FIRST_SIGNATURES);
|
||||
}
|
||||
|
||||
return limitedUnique(
|
||||
node.children.flatMap(
|
||||
(child) => summaries.get(child)?.firstSignatures ?? [],
|
||||
),
|
||||
MAXIMUM_FIRST_SIGNATURES,
|
||||
);
|
||||
}
|
||||
|
||||
function collectRecords(root: NormalizedRegexNode): {
|
||||
readonly records: readonly NodeRecord[];
|
||||
readonly truncated: boolean;
|
||||
} {
|
||||
const records: NodeRecord[] = [];
|
||||
const stack: NodeRecord[] = [{ node: root, depth: 0 }];
|
||||
while (stack.length > 0 && records.length < MAXIMUM_STATIC_ANALYSIS_NODES) {
|
||||
const record = stack.pop();
|
||||
if (!record) break;
|
||||
records.push(record);
|
||||
for (let index = record.node.children.length - 1; index >= 0; index -= 1) {
|
||||
const child = record.node.children[index];
|
||||
if (!child) continue;
|
||||
stack.push({
|
||||
node: child,
|
||||
depth: record.depth + 1,
|
||||
parent: record.node,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { records, truncated: stack.length > 0 };
|
||||
}
|
||||
|
||||
function buildSummaries(
|
||||
records: readonly NodeRecord[],
|
||||
): ReadonlyMap<NormalizedRegexNode, NodeSummary> {
|
||||
const summaries = new Map<NormalizedRegexNode, NodeSummary>();
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const node = records[index]?.node;
|
||||
if (!node) continue;
|
||||
const childSummaries = node.children
|
||||
.map((child) => summaries.get(child))
|
||||
.filter((summary): summary is NodeSummary => summary !== undefined);
|
||||
const overlapping = hasOverlappingChildren(node, summaries);
|
||||
summaries.set(node, {
|
||||
containsRepeatableQuantifier:
|
||||
isRepeatableQuantifier(node) ||
|
||||
childSummaries.some((summary) => summary.containsRepeatableQuantifier),
|
||||
firstRepeatableQuantifier:
|
||||
(isRepeatableQuantifier(node) ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstRepeatableQuantifier)
|
||||
?.firstRepeatableQuantifier,
|
||||
firstBackreference:
|
||||
(node.kind === "backreference" ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstBackreference)
|
||||
?.firstBackreference,
|
||||
firstWildcard:
|
||||
(node.kind === "dot" ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstWildcard)?.firstWildcard,
|
||||
firstAmbiguousAlternation:
|
||||
(overlapping ? node : undefined) ??
|
||||
childSummaries.find((summary) => summary.firstAmbiguousAlternation)
|
||||
?.firstAmbiguousAlternation,
|
||||
firstSignatures: firstSignaturesFor(node, summaries),
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function finding(
|
||||
flavour: RegexFlavourId,
|
||||
sequence: number,
|
||||
details: FindingDetails,
|
||||
): RegexRiskFinding {
|
||||
return {
|
||||
id: `risk-${details.rule}-${details.node.range.startUtf16}-${details.node.range.endUtf16}-${sequence}`,
|
||||
flavour,
|
||||
range: details.node.range,
|
||||
rule: details.rule,
|
||||
title: details.title,
|
||||
explanation: details.explanation,
|
||||
exampleRisk: details.exampleRisk,
|
||||
confidence: details.confidence,
|
||||
severity: details.severity,
|
||||
limitations: details.limitations,
|
||||
suggestedInvestigation: details.suggestedInvestigation,
|
||||
evidence: "static",
|
||||
};
|
||||
}
|
||||
|
||||
function startsAtInput(root: NormalizedRegexNode): boolean {
|
||||
return root.raw.startsWith("^");
|
||||
}
|
||||
|
||||
function severityOrder(severity: RegexRiskSeverity): number {
|
||||
if (severity === "high") return 0;
|
||||
if (severity === "warning") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function unsupportedReport(request: StaticRiskRequest): StaticRiskReport {
|
||||
return {
|
||||
flavour: request.flavour,
|
||||
analyser: {
|
||||
id: "regex-tools-ecmascript-heuristics",
|
||||
version: "1",
|
||||
support: "unsupported-flavour",
|
||||
},
|
||||
findings: [],
|
||||
analysedNodes: 0,
|
||||
findingsTruncated: false,
|
||||
traversalTruncated: false,
|
||||
summary: `Static risk analysis is unavailable for ${request.flavour}.`,
|
||||
limitations: [
|
||||
"The current analyser is scoped only to the normalized ECMAScript 2025 grammar.",
|
||||
"No ECMAScript heuristic is applied to another flavour.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function analyseStaticRisk(
|
||||
request: StaticRiskRequest,
|
||||
): StaticRiskReport {
|
||||
if (
|
||||
request.flavour !== "ecmascript" ||
|
||||
request.root.support.flavour !== "ecmascript"
|
||||
) {
|
||||
return unsupportedReport(request);
|
||||
}
|
||||
|
||||
const { records, truncated: traversalTruncated } = collectRecords(
|
||||
request.root,
|
||||
);
|
||||
const summaries = buildSummaries(records);
|
||||
const details: FindingDetails[] = [];
|
||||
let captureCount = 0;
|
||||
let deepest = records[0];
|
||||
const expensivePrefixCandidates: NormalizedRegexNode[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
const { node, depth } = record;
|
||||
if (node.capture) captureCount += 1;
|
||||
if (!deepest || depth > deepest.depth) deepest = record;
|
||||
const summary = summaries.get(node);
|
||||
if (!summary) continue;
|
||||
|
||||
if (isRepeatableQuantifier(node)) {
|
||||
const body = node.children[0];
|
||||
const bodySummary = body ? summaries.get(body) : undefined;
|
||||
const innerQuantifier =
|
||||
bodySummary?.firstRepeatableQuantifier === node
|
||||
? undefined
|
||||
: bodySummary?.firstRepeatableQuantifier;
|
||||
if (innerQuantifier) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "nested-quantifier",
|
||||
title: "Potential nested-quantifier risk",
|
||||
explanation:
|
||||
"A repeated expression contains another repeatable expression. Some failing inputs can make a backtracking engine revisit the same partitions.",
|
||||
exampleRisk:
|
||||
"Long near-misses may take disproportionately longer than matching inputs.",
|
||||
confidence: "high",
|
||||
severity: "high",
|
||||
limitations:
|
||||
"This structural heuristic does not model engine optimizations, atomicity or every surrounding constraint.",
|
||||
suggestedInvestigation:
|
||||
"Run bounded growth analysis with a representative near-miss and consider removing one repetition layer.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (body?.properties.nullable === true) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "nullable-repeated-expression",
|
||||
title: "Potential nullable-repetition risk",
|
||||
explanation:
|
||||
"The repeated body can match an empty string, so many repetition paths can describe the same input position.",
|
||||
exampleRisk:
|
||||
"Backtracking work or zero-length iteration can grow without consuming input.",
|
||||
confidence: "high",
|
||||
severity: "high",
|
||||
limitations:
|
||||
"Native engines can simplify some nullable repetitions; this analyser does not inspect the compiled program.",
|
||||
suggestedInvestigation:
|
||||
"Require the body to consume input, or test the exact pattern under strict timeout limits.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (bodySummary?.firstAmbiguousAlternation) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "ambiguous-repeated-alternatives",
|
||||
title: "Potential ambiguous repeated alternatives",
|
||||
explanation:
|
||||
"Alternatives under this repetition can begin with overlapping input, leaving multiple possible partitions.",
|
||||
exampleRisk:
|
||||
"A long suffix failure can force the engine to reconsider many alternative boundaries.",
|
||||
confidence: "medium",
|
||||
severity: "high",
|
||||
limitations:
|
||||
"Overlap is approximated from first consuming constructs; later tokens and engine optimizations are not proven.",
|
||||
suggestedInvestigation:
|
||||
"Make alternatives prefix-distinct, factor shared prefixes, and probe representative near-misses.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (bodySummary?.firstWildcard) {
|
||||
details.push({
|
||||
node: bodySummary.firstWildcard,
|
||||
rule: "repeated-wildcard",
|
||||
title: "Potential repeated-wildcard risk",
|
||||
explanation:
|
||||
"A wildcard occurs inside a repeated region and may consume the same text using multiple boundaries.",
|
||||
exampleRisk:
|
||||
"Later required text can trigger broad backtracking across the wildcard.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"Anchors, following literals and engine search optimizations can substantially change the observed cost.",
|
||||
suggestedInvestigation:
|
||||
"Use a more specific character class or a bounded repetition, then compare bounded growth results.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
if (bodySummary?.firstBackreference) {
|
||||
details.push({
|
||||
node: bodySummary.firstBackreference,
|
||||
rule: "backreference-in-repetition",
|
||||
title: "Potential repeated-backreference risk",
|
||||
explanation:
|
||||
"A backreference is evaluated inside repetition, coupling later work to previously captured text.",
|
||||
exampleRisk:
|
||||
"Multiple capture and repetition choices can amplify failing-input work.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"The analyser does not resolve every capture path or prove that competing paths are reachable.",
|
||||
suggestedInvestigation:
|
||||
"Test realistic capture lengths and near-misses with the bounded growth runner.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
node.children.length > 1 &&
|
||||
summary.firstAmbiguousAlternation === node
|
||||
) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "overlapping-alternatives",
|
||||
title: "Possibly overlapping alternatives",
|
||||
explanation:
|
||||
"At least two alternatives can begin with the same approximated input class.",
|
||||
exampleRisk:
|
||||
"The engine may need to try more than one branch when later tokens fail.",
|
||||
confidence: "low",
|
||||
severity: "notice",
|
||||
limitations:
|
||||
"Only first-token overlap is checked. This is neither an equivalence test nor proof that costly backtracking occurs.",
|
||||
suggestedInvestigation:
|
||||
"Review whether the alternatives can be made prefix-distinct or whether their shared prefix can be factored out.",
|
||||
});
|
||||
}
|
||||
|
||||
if (isLookaround(node) && summary.containsRepeatableQuantifier) {
|
||||
details.push({
|
||||
node,
|
||||
rule: "repeated-lookaround-body",
|
||||
title: "Potential expensive lookaround",
|
||||
explanation:
|
||||
"This lookaround evaluates a body containing repetition without consuming input at the assertion site.",
|
||||
exampleRisk:
|
||||
"An unanchored search may re-evaluate the repeated assertion at many candidate positions.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"The analyser does not know which assertion results the runtime caches or optimizes.",
|
||||
suggestedInvestigation:
|
||||
"Anchor or narrow the assertion where possible and test a representative non-match.",
|
||||
});
|
||||
expensivePrefixCandidates.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (captureCount > EXCESSIVE_CAPTURE_THRESHOLD) {
|
||||
details.push({
|
||||
node: request.root,
|
||||
rule: "excessive-captures",
|
||||
title: "Large capture set",
|
||||
explanation: `The pattern contains ${captureCount.toLocaleString()} capturing groups, above this analyser’s ${EXCESSIVE_CAPTURE_THRESHOLD.toLocaleString()}-group review threshold.`,
|
||||
exampleRisk:
|
||||
"Capturing can increase result materialization, offset conversion and replacement work.",
|
||||
confidence: "high",
|
||||
severity: "notice",
|
||||
limitations:
|
||||
"The threshold is an application review limit, not an engine vulnerability boundary.",
|
||||
suggestedInvestigation:
|
||||
"Convert groups that are not consumed by results or backreferences to non-capturing groups.",
|
||||
});
|
||||
}
|
||||
|
||||
if (deepest && deepest.depth > EXCESSIVE_NESTING_THRESHOLD) {
|
||||
details.push({
|
||||
node: deepest.node,
|
||||
rule: "excessive-nesting",
|
||||
title: "Deeply nested pattern",
|
||||
explanation: `The normalized tree reaches depth ${deepest.depth.toLocaleString()}, above this analyser’s ${EXCESSIVE_NESTING_THRESHOLD.toLocaleString()}-level review threshold.`,
|
||||
exampleRisk:
|
||||
"Deep nesting can increase compiler, stack or explanation work even when matching remains quick.",
|
||||
confidence: "high",
|
||||
severity: "notice",
|
||||
limitations:
|
||||
"Normalized AST depth is not identical to the native engine’s internal program depth.",
|
||||
suggestedInvestigation:
|
||||
"Simplify unnecessary grouping and retain strict compile and execution limits.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!startsAtInput(request.root) && expensivePrefixCandidates[0]) {
|
||||
details.push({
|
||||
node: expensivePrefixCandidates[0],
|
||||
rule: "unanchored-expensive-prefix",
|
||||
title: "Potential unanchored search amplification",
|
||||
explanation:
|
||||
"The pattern is not start-anchored and contains a structurally expensive region, so the engine may retry it at many subject positions.",
|
||||
exampleRisk:
|
||||
"A long non-match can multiply per-position backtracking by the subject length.",
|
||||
confidence: request.flags.includes("y") ? "low" : "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"Sticky execution, runtime prefix scans and calling-code start offsets can avoid retries that are possible from the pattern alone.",
|
||||
suggestedInvestigation:
|
||||
"Use an appropriate start anchor or sticky execution when the intended match must begin at a known position.",
|
||||
});
|
||||
}
|
||||
|
||||
const replacement = request.replacement ?? "";
|
||||
const repeatedExecution = request.scanAll || request.flags.includes("g");
|
||||
if (
|
||||
replacement.length > 0 &&
|
||||
repeatedExecution &&
|
||||
(replacement.includes("$`") ||
|
||||
replacement.includes("$'") ||
|
||||
replacement.length > 4_096)
|
||||
) {
|
||||
details.push({
|
||||
node: request.root,
|
||||
rule: "potential-replacement-expansion",
|
||||
title: "Potentially large replacement output",
|
||||
explanation:
|
||||
replacement.includes("$`") || replacement.includes("$'")
|
||||
? "The replacement refers to an entire subject prefix or suffix and can do so for every selected match."
|
||||
: "A long replacement template can be emitted once for every selected match.",
|
||||
exampleRisk:
|
||||
"Output size and replacement time can grow much faster than the input preview suggests.",
|
||||
confidence: "medium",
|
||||
severity: "warning",
|
||||
limitations:
|
||||
"The finding uses pattern and template structure; it does not predict the actual match count for every subject.",
|
||||
suggestedInvestigation:
|
||||
"Use bounded replacement previews and test output size on representative maximum inputs.",
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique = details.filter((item) => {
|
||||
const key = `${item.rule}:${item.node.range.startUtf16}:${item.node.range.endUtf16}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
unique.sort(
|
||||
(left, right) =>
|
||||
severityOrder(left.severity) - severityOrder(right.severity) ||
|
||||
left.node.range.startUtf16 - right.node.range.startUtf16 ||
|
||||
left.rule.localeCompare(right.rule),
|
||||
);
|
||||
const findingsTruncated = unique.length > MAXIMUM_STATIC_RISK_FINDINGS;
|
||||
const findings = unique
|
||||
.slice(0, MAXIMUM_STATIC_RISK_FINDINGS)
|
||||
.map((item, index) => finding(request.flavour, index + 1, item))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
severityOrder(left.severity) - severityOrder(right.severity) ||
|
||||
left.range.startUtf16 - right.range.startUtf16 ||
|
||||
left.rule.localeCompare(right.rule),
|
||||
);
|
||||
|
||||
return {
|
||||
flavour: request.flavour,
|
||||
analyser: {
|
||||
id: "regex-tools-ecmascript-heuristics",
|
||||
version: "1",
|
||||
support: "experimental-ecmascript-2025",
|
||||
},
|
||||
findings,
|
||||
analysedNodes: records.length,
|
||||
findingsTruncated,
|
||||
traversalTruncated,
|
||||
summary:
|
||||
findings.length === 0
|
||||
? "No issue found by this analyser. This is not a safety guarantee."
|
||||
: `${findings.length.toLocaleString()} potential ${
|
||||
findings.length === 1 ? "risk" : "risks"
|
||||
} found by ECMAScript-specific structural heuristics.`,
|
||||
limitations: [
|
||||
"Findings are advisory and do not model the native engine’s complete compiled program or optimizations.",
|
||||
"Absence of a finding does not mean that a pattern is safe or linear.",
|
||||
"Dynamic probes characterize only the generated inputs and limits selected by the user.",
|
||||
...(traversalTruncated
|
||||
? [
|
||||
`Traversal stopped at ${MAXIMUM_STATIC_ANALYSIS_NODES.toLocaleString()} normalized nodes.`,
|
||||
]
|
||||
: []),
|
||||
...(findingsTruncated
|
||||
? [
|
||||
`Findings stopped at ${MAXIMUM_STATIC_RISK_FINDINGS.toLocaleString()} entries.`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
75
src/regex/analysis/statistics.test.ts
Normal file
75
src/regex/analysis/statistics.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { metricStatistics, summarizeBenchmarkSamples } from "./statistics";
|
||||
import type { BenchmarkWorkerSample } from "./analysis.types";
|
||||
|
||||
function sample(
|
||||
compileMs: number,
|
||||
overrides: Partial<BenchmarkWorkerSample> = {},
|
||||
): BenchmarkWorkerSample {
|
||||
return {
|
||||
accepted: true,
|
||||
effectiveFlags: "g",
|
||||
subjectBytes: 10,
|
||||
subjectUtf16: 10,
|
||||
compileMs,
|
||||
firstMatchMs: compileMs + 1,
|
||||
allMatchesMs: compileMs + 2,
|
||||
replacementMs: compileMs + 3,
|
||||
throughputBytesPerSecond: 100 / (compileMs + 1),
|
||||
matchCount: 1,
|
||||
matched: true,
|
||||
matchCollectionTruncated: false,
|
||||
replacementOutputUtf16: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("analysis statistics", () => {
|
||||
it("computes a conventional median and nearest-rank p95", () => {
|
||||
expect(metricStatistics([4, 1, 3, 2])).toEqual({
|
||||
count: 4,
|
||||
minimum: 1,
|
||||
median: 2.5,
|
||||
p95: 4,
|
||||
maximum: 4,
|
||||
});
|
||||
expect(
|
||||
metricStatistics(Array.from({ length: 100 }, (_, index) => index + 1))
|
||||
?.p95,
|
||||
).toBe(95);
|
||||
});
|
||||
|
||||
it("ignores absent, negative and non-finite measurements", () => {
|
||||
expect(
|
||||
metricStatistics([
|
||||
undefined,
|
||||
Number.NaN,
|
||||
-1,
|
||||
0,
|
||||
Number.POSITIVE_INFINITY,
|
||||
]),
|
||||
).toEqual({
|
||||
count: 1,
|
||||
minimum: 0,
|
||||
median: 0,
|
||||
p95: 0,
|
||||
maximum: 0,
|
||||
});
|
||||
expect(metricStatistics([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summarizes only measurements actually produced by the worker", () => {
|
||||
const summary = summarizeBenchmarkSamples([
|
||||
sample(1),
|
||||
sample(3, {
|
||||
replacementMs: undefined,
|
||||
replacementSkippedReason: "bounded output estimate",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.compileMs?.median).toBe(2);
|
||||
expect(summary.replacementMs).toEqual(
|
||||
expect.objectContaining({ count: 1, median: 4 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
61
src/regex/analysis/statistics.ts
Normal file
61
src/regex/analysis/statistics.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
BenchmarkMetricSummary,
|
||||
BenchmarkWorkerSample,
|
||||
MetricStatistics,
|
||||
} from "./analysis.types";
|
||||
|
||||
function finite(values: readonly (number | undefined)[]): number[] {
|
||||
return values.filter(
|
||||
(value): value is number =>
|
||||
value !== undefined && Number.isFinite(value) && value >= 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function metricStatistics(
|
||||
values: readonly (number | undefined)[],
|
||||
): MetricStatistics | undefined {
|
||||
const ordered = finite(values).sort((left, right) => left - right);
|
||||
if (ordered.length === 0) return undefined;
|
||||
const middle = Math.floor(ordered.length / 2);
|
||||
const median =
|
||||
ordered.length % 2 === 0
|
||||
? ((ordered[middle - 1] ?? 0) + (ordered[middle] ?? 0)) / 2
|
||||
: (ordered[middle] ?? 0);
|
||||
const p95Index = Math.max(0, Math.ceil(ordered.length * 0.95) - 1);
|
||||
return {
|
||||
count: ordered.length,
|
||||
minimum: ordered[0] ?? 0,
|
||||
median,
|
||||
p95: ordered[p95Index] ?? ordered.at(-1) ?? 0,
|
||||
maximum: ordered.at(-1) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeBenchmarkSamples(
|
||||
samples: readonly BenchmarkWorkerSample[],
|
||||
): BenchmarkMetricSummary {
|
||||
const metric = (
|
||||
select: (sample: BenchmarkWorkerSample) => number | undefined,
|
||||
) => metricStatistics(samples.map(select));
|
||||
return {
|
||||
...(metric((sample) => sample.compileMs)
|
||||
? { compileMs: metric((sample) => sample.compileMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.firstMatchMs)
|
||||
? { firstMatchMs: metric((sample) => sample.firstMatchMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.allMatchesMs)
|
||||
? { allMatchesMs: metric((sample) => sample.allMatchesMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.replacementMs)
|
||||
? { replacementMs: metric((sample) => sample.replacementMs) }
|
||||
: {}),
|
||||
...(metric((sample) => sample.throughputBytesPerSecond)
|
||||
? {
|
||||
throughputBytesPerSecond: metric(
|
||||
(sample) => sample.throughputBytesPerSecond,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
116
src/regex/codegen/pcre2-c.test.ts
Normal file
116
src/regex/codegen/pcre2-c.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generatePcre2C, type Pcre2CGenerationRequest } from "./pcre2-c";
|
||||
|
||||
function request(
|
||||
values: Partial<Pcre2CGenerationRequest> = {},
|
||||
): Pcre2CGenerationRequest {
|
||||
return {
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
|
||||
operation: "match",
|
||||
pattern: "(?<value>.+)",
|
||||
flags: ["g", "i", "m", "s", "x", "U", "J"],
|
||||
options: {
|
||||
matchLimit: 1_000_000,
|
||||
depthLimit: 1_000,
|
||||
heapLimitKib: 32_768,
|
||||
},
|
||||
subject: "value",
|
||||
scanAll: true,
|
||||
maximumMatches: 1_000,
|
||||
maximumCaptureRows: 10_000,
|
||||
maximumOutputBytes: 1024 * 1024,
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PCRE2 10.47 C generator", () => {
|
||||
it("emits exact UTF-8 byte arrays instead of interpolating host literals", () => {
|
||||
const pattern = '(?<x>["\\\\${}]+\\R)';
|
||||
const subject = "“quote” \\\nGrüße ${value}\0";
|
||||
const replacement = '${x} "$" \\\\';
|
||||
const generated = generatePcre2C(
|
||||
request({
|
||||
operation: "replace",
|
||||
pattern,
|
||||
subject,
|
||||
replacement,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generated).toMatchObject({
|
||||
target: "c17-pcre2-8",
|
||||
fileName: "regex-tools-pcre2-replace.c",
|
||||
engineIdentity: "PCRE2 10.47 8-bit",
|
||||
represents: "replacement",
|
||||
});
|
||||
expect(generated.source).toContain(
|
||||
'#error "This generated program requires PCRE2 10.47 exactly."',
|
||||
);
|
||||
expect(generated.source).toContain(
|
||||
"PCRE2_UTF | PCRE2_UCP | PCRE2_CASELESS | PCRE2_MULTILINE | PCRE2_DOTALL | PCRE2_EXTENDED | PCRE2_UNGREEDY | PCRE2_DUPNAMES",
|
||||
);
|
||||
expect(generated.source).toContain(
|
||||
"static const PCRE2_UCHAR replacement[] = {",
|
||||
);
|
||||
expect(generated.source).toContain(
|
||||
"0x47, 0x72, 0xc3, 0xbc, 0xc3, 0x9f, 0x65",
|
||||
);
|
||||
expect(generated.source).not.toContain(pattern);
|
||||
expect(generated.source).not.toContain(subject);
|
||||
expect(generated.source).not.toContain(replacement);
|
||||
expect(generated.caveats.join(" ")).toMatch(
|
||||
/checked only after substitution/u,
|
||||
);
|
||||
expect(generated.caveats.join(" ")).toMatch(/wall-clock timeout/u);
|
||||
});
|
||||
|
||||
it("states and implements application-level all-match iteration", () => {
|
||||
const generated = generatePcre2C(
|
||||
request({
|
||||
flags: [],
|
||||
scanAll: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generated.represents).toBe("all-matches");
|
||||
expect(generated.source).toContain("static PCRE2_SIZE advance_utf8");
|
||||
expect(generated.source).toContain(
|
||||
"PCRE2_NOTEMPTY_ATSTART | PCRE2_ANCHORED",
|
||||
);
|
||||
expect(generated.source).toContain(
|
||||
"Regex Tools host-side match/capture result cap reached.",
|
||||
);
|
||||
expect(generated.source).toContain(
|
||||
"pcre2_set_match_limit(context, match_limit)",
|
||||
);
|
||||
expect(generated.source).toContain(
|
||||
"pcre2_set_depth_limit(context, depth_limit)",
|
||||
);
|
||||
expect(generated.source).toContain(
|
||||
"pcre2_set_heap_limit(context, heap_limit_kib)",
|
||||
);
|
||||
});
|
||||
|
||||
it("generates a first-match program only when neither g nor scan-all is selected", () => {
|
||||
const generated = generatePcre2C(request({ flags: ["i"], scanAll: false }));
|
||||
|
||||
expect(generated.represents).toBe("first-match");
|
||||
expect(generated.source).toContain("if (!false)");
|
||||
expect(generated.caveats).toContain(
|
||||
"The program requests only the first match.",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on unsupported mappings and lossy browser Unicode", () => {
|
||||
expect(() => generatePcre2C(request({ flags: ["u"] }))).toThrow(
|
||||
/unsupported/u,
|
||||
);
|
||||
expect(() => generatePcre2C(request({ pattern: "\ud800" }))).toThrow(
|
||||
/unpaired UTF-16 surrogate/u,
|
||||
);
|
||||
expect(() =>
|
||||
generatePcre2C(request({ operation: "replace", replacement: undefined })),
|
||||
).toThrow(/requires an exact replacement/u);
|
||||
});
|
||||
});
|
||||
591
src/regex/codegen/pcre2-c.ts
Normal file
591
src/regex/codegen/pcre2-c.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
import {
|
||||
AVAILABLE_REGEX_FLAVOURS,
|
||||
parseRegexFlags,
|
||||
parseRegexOptions,
|
||||
resolveRegexFlavourVersion,
|
||||
} from "../flavours/flavour-registry";
|
||||
import type { RegexEngineOptions } from "../model/flavour";
|
||||
import {
|
||||
DEFAULT_REGEX_LIMITS,
|
||||
utf8ByteLength,
|
||||
} from "../execution/request-limits";
|
||||
|
||||
export type Pcre2COperation = "match" | "replace";
|
||||
|
||||
export interface Pcre2CGenerationRequest {
|
||||
readonly flavour: "pcre2";
|
||||
readonly flavourVersion: string;
|
||||
readonly operation: Pcre2COperation;
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly subject: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly replacement?: string;
|
||||
readonly maximumMatches: number;
|
||||
readonly maximumCaptureRows: number;
|
||||
readonly maximumOutputBytes: number;
|
||||
}
|
||||
|
||||
export interface GeneratedPcre2CProgram {
|
||||
readonly target: "c17-pcre2-8";
|
||||
readonly fileName: string;
|
||||
readonly source: string;
|
||||
readonly compileCommand: string;
|
||||
readonly engineIdentity: string;
|
||||
readonly represents: "first-match" | "all-matches" | "replacement";
|
||||
readonly caveats: readonly string[];
|
||||
}
|
||||
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const MAXIMUM_GENERATED_TEXT_BYTES = 1024 * 1024;
|
||||
const PCRE2_VERSION = "PCRE2 10.47 8-bit";
|
||||
|
||||
function integer(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertUnicodeScalarText(value: string, label: string): void {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const first = value.charCodeAt(index);
|
||||
if (first >= 0xd800 && first <= 0xdbff) {
|
||||
const second = value.charCodeAt(index + 1);
|
||||
if (second >= 0xdc00 && second <= 0xdfff) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`${label} contains an unpaired UTF-16 surrogate and cannot be emitted as exact UTF-8.`,
|
||||
);
|
||||
}
|
||||
if (first >= 0xdc00 && first <= 0xdfff) {
|
||||
throw new Error(
|
||||
`${label} contains an unpaired UTF-16 surrogate and cannot be emitted as exact UTF-8.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function byteArray(name: string, value: string): string {
|
||||
const bytes = [...TEXT_ENCODER.encode(value), 0];
|
||||
const rows: string[] = [];
|
||||
for (let index = 0; index < bytes.length; index += 12) {
|
||||
rows.push(
|
||||
` ${bytes
|
||||
.slice(index, index + 12)
|
||||
.map((byte) => `0x${byte.toString(16).padStart(2, "0")}`)
|
||||
.join(", ")},`,
|
||||
);
|
||||
}
|
||||
return [
|
||||
`static const PCRE2_UCHAR ${name}[] = {`,
|
||||
...rows,
|
||||
"};",
|
||||
`static const PCRE2_SIZE ${name}_length = sizeof(${name}) - 1;`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function compileOptions(flags: readonly string[]): string {
|
||||
const options = [
|
||||
"PCRE2_UTF",
|
||||
"PCRE2_UCP",
|
||||
...(flags.includes("i") ? ["PCRE2_CASELESS"] : []),
|
||||
...(flags.includes("m") ? ["PCRE2_MULTILINE"] : []),
|
||||
...(flags.includes("s") ? ["PCRE2_DOTALL"] : []),
|
||||
...(flags.includes("x") ? ["PCRE2_EXTENDED"] : []),
|
||||
...(flags.includes("U") ? ["PCRE2_UNGREEDY"] : []),
|
||||
...(flags.includes("J") ? ["PCRE2_DUPNAMES"] : []),
|
||||
];
|
||||
return options.join(" | ");
|
||||
}
|
||||
|
||||
function commonSource(
|
||||
request: Pcre2CGenerationRequest,
|
||||
options: Readonly<{
|
||||
matchLimit: number;
|
||||
depthLimit: number;
|
||||
heapLimitKib: number;
|
||||
}>,
|
||||
): string {
|
||||
return `/*
|
||||
* Generated by add·ideas Regex Tools for the PCRE2 10.47 8-bit C API.
|
||||
* Pattern, subject and replacement are independent exact UTF-8 byte arrays.
|
||||
* PCRE2_UTF and PCRE2_UCP are always enabled to match the browser runtime.
|
||||
*
|
||||
* Build:
|
||||
* cc -std=c17 -Wall -Wextra -Werror regex-tools-pcre2.c \\
|
||||
* $(pkg-config --cflags --libs libpcre2-8) -o regex-tools-pcre2
|
||||
*
|
||||
* There is no native PCRE2 wall-clock timeout. Run this program in a process
|
||||
* that your application can terminate if a hard elapsed-time limit is needed.
|
||||
*/
|
||||
#define PCRE2_CODE_UNIT_WIDTH 8
|
||||
#include <pcre2.h>
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#if PCRE2_MAJOR != 10 || PCRE2_MINOR != 47
|
||||
#error "This generated program requires PCRE2 10.47 exactly."
|
||||
#endif
|
||||
|
||||
${byteArray("pattern", request.pattern)}
|
||||
|
||||
${byteArray("subject", request.subject)}
|
||||
|
||||
static const uint32_t compile_options = ${compileOptions(request.flags)};
|
||||
static const uint32_t match_limit = ${options.matchLimit}u;
|
||||
static const uint32_t depth_limit = ${options.depthLimit}u;
|
||||
static const uint32_t heap_limit_kib = ${options.heapLimitKib}u;
|
||||
static const size_t maximum_matches = ${request.maximumMatches}u;
|
||||
static const size_t maximum_capture_rows = ${request.maximumCaptureRows}u;
|
||||
|
||||
static void print_pcre2_error(const char *phase, int code) {
|
||||
PCRE2_UCHAR message[256];
|
||||
int length = pcre2_get_error_message(code, message, sizeof(message));
|
||||
if (length < 0) {
|
||||
fprintf(stderr, "%s failed with PCRE2 error %d.\\n", phase, code);
|
||||
return;
|
||||
}
|
||||
fprintf(stderr, "%s failed: %.*s (PCRE2 error %d).\\n",
|
||||
phase, length, (const char *)message, code);
|
||||
}
|
||||
|
||||
static bool verify_runtime_version(void) {
|
||||
char version[64] = {0};
|
||||
uint32_t unicode = 0;
|
||||
int status = pcre2_config(PCRE2_CONFIG_VERSION, version);
|
||||
if (status < 0) {
|
||||
print_pcre2_error("pcre2_config(PCRE2_CONFIG_VERSION)", status);
|
||||
return false;
|
||||
}
|
||||
if (strncmp(version, "10.47 ", 6) != 0) {
|
||||
fprintf(stderr, "Expected PCRE2 10.47, loaded %s.\\n", version);
|
||||
return false;
|
||||
}
|
||||
status = pcre2_config(PCRE2_CONFIG_UNICODE, &unicode);
|
||||
if (status != 0 || unicode != 1) {
|
||||
fputs("The loaded PCRE2 10.47 library has no Unicode support.\\n", stderr);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool configure_limits(pcre2_match_context *context) {
|
||||
int status = pcre2_set_match_limit(context, match_limit);
|
||||
if (status != 0) {
|
||||
print_pcre2_error("pcre2_set_match_limit", status);
|
||||
return false;
|
||||
}
|
||||
status = pcre2_set_depth_limit(context, depth_limit);
|
||||
if (status != 0) {
|
||||
print_pcre2_error("pcre2_set_depth_limit", status);
|
||||
return false;
|
||||
}
|
||||
status = pcre2_set_heap_limit(context, heap_limit_kib);
|
||||
if (status != 0) {
|
||||
print_pcre2_error("pcre2_set_heap_limit", status);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}`;
|
||||
}
|
||||
|
||||
function matchSource(
|
||||
request: Pcre2CGenerationRequest,
|
||||
global: boolean,
|
||||
): string {
|
||||
return `${commonSource(request, {
|
||||
matchLimit: Number(request.options.matchLimit),
|
||||
depthLimit: Number(request.options.depthLimit),
|
||||
heapLimitKib: Number(request.options.heapLimitKib),
|
||||
})}
|
||||
|
||||
/*
|
||||
* PCRE2's "global" flag is an application loop, not a compile option.
|
||||
* This generated loop implements the empty-match anchored retry described by
|
||||
* the PCRE2 API and advances by one UTF-8 character only after that retry
|
||||
* reports no match.
|
||||
*/
|
||||
static PCRE2_SIZE advance_utf8(PCRE2_SIZE offset, bool crlf_is_newline) {
|
||||
if (offset >= subject_length) {
|
||||
return subject_length + 1;
|
||||
}
|
||||
if (crlf_is_newline && subject[offset] == 0x0d &&
|
||||
offset + 1 < subject_length &&
|
||||
subject[offset + 1] == 0x0a) {
|
||||
return offset + 2;
|
||||
}
|
||||
PCRE2_SIZE width =
|
||||
(subject[offset] & 0x80u) == 0 ? 1 :
|
||||
(subject[offset] & 0xe0u) == 0xc0u ? 2 :
|
||||
(subject[offset] & 0xf0u) == 0xe0u ? 3 : 4;
|
||||
return offset + width <= subject_length ? offset + width : subject_length + 1;
|
||||
}
|
||||
|
||||
static const char *capture_name(pcre2_code *code, uint32_t group) {
|
||||
uint32_t name_count = 0;
|
||||
uint32_t entry_size = 0;
|
||||
PCRE2_SPTR table = NULL;
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_NAMECOUNT, &name_count) != 0 ||
|
||||
name_count == 0 ||
|
||||
pcre2_pattern_info(code, PCRE2_INFO_NAMEENTRYSIZE, &entry_size) != 0 ||
|
||||
pcre2_pattern_info(code, PCRE2_INFO_NAMETABLE, &table) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
for (uint32_t index = 0; index < name_count; index += 1) {
|
||||
PCRE2_SPTR entry = table + index * entry_size;
|
||||
uint32_t number = ((uint32_t)entry[0] << 8) | entry[1];
|
||||
if (number == group) {
|
||||
return (const char *)(entry + 2);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
int exit_code = 1;
|
||||
int error_code = 0;
|
||||
PCRE2_SIZE error_offset = 0;
|
||||
pcre2_code *code = NULL;
|
||||
pcre2_match_data *match_data = NULL;
|
||||
pcre2_match_context *match_context = NULL;
|
||||
size_t match_count = 0;
|
||||
size_t capture_rows = 0;
|
||||
PCRE2_SIZE search_offset = 0;
|
||||
uint32_t match_options = 0;
|
||||
bool crlf_is_newline = false;
|
||||
|
||||
if (!verify_runtime_version()) {
|
||||
return 1;
|
||||
}
|
||||
code = pcre2_compile(pattern, pattern_length, compile_options,
|
||||
&error_code, &error_offset, NULL);
|
||||
if (code == NULL) {
|
||||
fprintf(stderr, "Compile error at pattern UTF-8 byte %" PRIuPTR ": ",
|
||||
(uintptr_t)error_offset);
|
||||
print_pcre2_error("pcre2_compile", error_code);
|
||||
goto cleanup;
|
||||
}
|
||||
match_data = pcre2_match_data_create_from_pattern(code, NULL);
|
||||
match_context = pcre2_match_context_create(NULL);
|
||||
if (match_data == NULL || match_context == NULL) {
|
||||
fputs("Could not allocate PCRE2 match state.\\n", stderr);
|
||||
goto cleanup;
|
||||
}
|
||||
if (!configure_limits(match_context)) {
|
||||
goto cleanup;
|
||||
}
|
||||
uint32_t newline = 0;
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_NEWLINE, &newline) != 0) {
|
||||
fputs("Could not read the PCRE2 newline convention.\\n", stderr);
|
||||
goto cleanup;
|
||||
}
|
||||
crlf_is_newline =
|
||||
newline == PCRE2_NEWLINE_CRLF ||
|
||||
newline == PCRE2_NEWLINE_ANY ||
|
||||
newline == PCRE2_NEWLINE_ANYCRLF;
|
||||
|
||||
while (search_offset <= subject_length) {
|
||||
int status = pcre2_match(code, subject, subject_length, search_offset,
|
||||
match_options, match_data, match_context);
|
||||
if (status == PCRE2_ERROR_NOMATCH && match_options != 0) {
|
||||
search_offset = advance_utf8(search_offset, crlf_is_newline);
|
||||
match_options = 0;
|
||||
continue;
|
||||
}
|
||||
if (status == PCRE2_ERROR_NOMATCH) {
|
||||
break;
|
||||
}
|
||||
if (status < 0) {
|
||||
print_pcre2_error("pcre2_match", status);
|
||||
goto cleanup;
|
||||
}
|
||||
if (status == 0) {
|
||||
fputs("The match vector was unexpectedly too small.\\n", stderr);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
uint32_t capture_count = 0;
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0) {
|
||||
fputs("Could not read the PCRE2 capture count.\\n", stderr);
|
||||
goto cleanup;
|
||||
}
|
||||
size_t required_rows = capture_count == 0 ? 1u : capture_count;
|
||||
if (match_count >= maximum_matches ||
|
||||
required_rows > maximum_capture_rows - capture_rows) {
|
||||
fputs("Regex Tools host-side match/capture result cap reached.\\n", stderr);
|
||||
exit_code = 3;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
PCRE2_SIZE *ovector = pcre2_get_ovector_pointer(match_data);
|
||||
printf("match %zu: UTF-8 bytes %" PRIuPTR "..%" PRIuPTR "\\n",
|
||||
match_count + 1, (uintptr_t)ovector[0], (uintptr_t)ovector[1]);
|
||||
for (uint32_t group = 0; group <= capture_count; group += 1) {
|
||||
PCRE2_SIZE start = ovector[group * 2];
|
||||
PCRE2_SIZE end = ovector[group * 2 + 1];
|
||||
const char *name = capture_name(code, group);
|
||||
if (start == PCRE2_UNSET || end == PCRE2_UNSET) {
|
||||
printf(" group %u%s%s: did not participate\\n",
|
||||
group, name == NULL ? "" : " / ", name == NULL ? "" : name);
|
||||
continue;
|
||||
}
|
||||
printf(" group %u%s%s: UTF-8 bytes %" PRIuPTR "..%" PRIuPTR " = ",
|
||||
group, name == NULL ? "" : " / ", name == NULL ? "" : name,
|
||||
(uintptr_t)start, (uintptr_t)end);
|
||||
(void)fwrite(subject + start, 1, (size_t)(end - start), stdout);
|
||||
fputc('\\n', stdout);
|
||||
}
|
||||
match_count += 1;
|
||||
capture_rows += required_rows;
|
||||
|
||||
if (!${global ? "true" : "false"}) {
|
||||
break;
|
||||
}
|
||||
search_offset = ovector[1];
|
||||
match_options =
|
||||
ovector[0] == ovector[1] ? PCRE2_NOTEMPTY_ATSTART | PCRE2_ANCHORED : 0;
|
||||
}
|
||||
|
||||
printf("completed: %zu match(es), %zu capture row(s)\\n",
|
||||
match_count, capture_rows);
|
||||
exit_code = 0;
|
||||
|
||||
cleanup:
|
||||
pcre2_match_context_free(match_context);
|
||||
pcre2_match_data_free(match_data);
|
||||
pcre2_code_free(code);
|
||||
return exit_code;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function replacementSource(
|
||||
request: Pcre2CGenerationRequest,
|
||||
global: boolean,
|
||||
): string {
|
||||
const replacement = request.replacement;
|
||||
if (replacement === undefined) {
|
||||
throw new Error("PCRE2 replacement generation requires a replacement.");
|
||||
}
|
||||
return `${commonSource(request, {
|
||||
matchLimit: Number(request.options.matchLimit),
|
||||
depthLimit: Number(request.options.depthLimit),
|
||||
heapLimitKib: Number(request.options.heapLimitKib),
|
||||
})}
|
||||
|
||||
${byteArray("replacement", replacement)}
|
||||
|
||||
static const size_t maximum_output_bytes = ${request.maximumOutputBytes}u;
|
||||
|
||||
int main(void) {
|
||||
int exit_code = 1;
|
||||
int error_code = 0;
|
||||
PCRE2_SIZE error_offset = 0;
|
||||
pcre2_code *code = NULL;
|
||||
pcre2_match_data *match_data = NULL;
|
||||
pcre2_match_context *match_context = NULL;
|
||||
PCRE2_UCHAR *output = NULL;
|
||||
|
||||
if (!verify_runtime_version()) {
|
||||
return 1;
|
||||
}
|
||||
code = pcre2_compile(pattern, pattern_length, compile_options,
|
||||
&error_code, &error_offset, NULL);
|
||||
if (code == NULL) {
|
||||
fprintf(stderr, "Compile error at pattern UTF-8 byte %" PRIuPTR ": ",
|
||||
(uintptr_t)error_offset);
|
||||
print_pcre2_error("pcre2_compile", error_code);
|
||||
goto cleanup;
|
||||
}
|
||||
match_data = pcre2_match_data_create_from_pattern(code, NULL);
|
||||
match_context = pcre2_match_context_create(NULL);
|
||||
output = malloc(maximum_output_bytes + 1);
|
||||
if (match_data == NULL || match_context == NULL || output == NULL) {
|
||||
fputs("Could not allocate PCRE2 replacement state.\\n", stderr);
|
||||
goto cleanup;
|
||||
}
|
||||
if (!configure_limits(match_context)) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
PCRE2_SIZE output_length = maximum_output_bytes;
|
||||
uint32_t substitute_options =
|
||||
PCRE2_SUBSTITUTE_UNSET_EMPTY${global ? " | PCRE2_SUBSTITUTE_GLOBAL" : ""};
|
||||
int substitutions = pcre2_substitute(
|
||||
code, subject, subject_length, 0, substitute_options,
|
||||
match_data, match_context, replacement, replacement_length,
|
||||
output, &output_length);
|
||||
if (substitutions == PCRE2_ERROR_NOMEMORY) {
|
||||
fprintf(stderr,
|
||||
"Replacement exceeds the %zu-byte Regex Tools output cap.\\n",
|
||||
maximum_output_bytes);
|
||||
exit_code = 3;
|
||||
goto cleanup;
|
||||
}
|
||||
if (substitutions < 0) {
|
||||
print_pcre2_error("pcre2_substitute", substitutions);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
uint32_t capture_count = 0;
|
||||
if (pcre2_pattern_info(code, PCRE2_INFO_CAPTURECOUNT, &capture_count) != 0) {
|
||||
fputs("Could not read the PCRE2 capture count.\\n", stderr);
|
||||
goto cleanup;
|
||||
}
|
||||
size_t rows_per_match = capture_count == 0 ? 1u : capture_count;
|
||||
if ((size_t)substitutions > maximum_matches ||
|
||||
((size_t)substitutions != 0 &&
|
||||
rows_per_match > maximum_capture_rows / (size_t)substitutions)) {
|
||||
fputs("Replacement completed but exceeded the Regex Tools result cap; "
|
||||
"output is withheld.\\n", stderr);
|
||||
exit_code = 3;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
(void)fwrite(output, 1, (size_t)output_length, stdout);
|
||||
fprintf(stderr, "\\ncompleted: %d substitution(s), %zu output bytes\\n",
|
||||
substitutions, (size_t)output_length);
|
||||
exit_code = 0;
|
||||
|
||||
cleanup:
|
||||
free(output);
|
||||
pcre2_match_context_free(match_context);
|
||||
pcre2_match_data_free(match_data);
|
||||
pcre2_code_free(code);
|
||||
return exit_code;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function validate(request: Pcre2CGenerationRequest): Pcre2CGenerationRequest {
|
||||
if (request.flavour !== "pcre2") {
|
||||
throw new Error("The PCRE2 C generator accepts only PCRE2 snapshots.");
|
||||
}
|
||||
const definition = AVAILABLE_REGEX_FLAVOURS.require("pcre2");
|
||||
const version = resolveRegexFlavourVersion(
|
||||
request.flavourVersion,
|
||||
definition,
|
||||
"PCRE2 code-generation version",
|
||||
).definition;
|
||||
if (version.value !== "PCRE2 10.47 8-bit WebAssembly") {
|
||||
throw new Error("The C generator is pinned to PCRE2 10.47 8-bit.");
|
||||
}
|
||||
const flags = parseRegexFlags(
|
||||
request.flags,
|
||||
definition,
|
||||
"PCRE2 code-generation flags",
|
||||
);
|
||||
const options = parseRegexOptions(
|
||||
request.options,
|
||||
definition,
|
||||
"PCRE2 code-generation options",
|
||||
);
|
||||
assertUnicodeScalarText(request.pattern, "Pattern");
|
||||
assertUnicodeScalarText(request.subject, "Subject");
|
||||
if (request.replacement !== undefined) {
|
||||
assertUnicodeScalarText(request.replacement, "Replacement");
|
||||
}
|
||||
if (utf8ByteLength(request.pattern) > 1024 * 1024) {
|
||||
throw new RangeError("PCRE2 generated patterns are limited to 1 MiB.");
|
||||
}
|
||||
if (utf8ByteLength(request.subject) > MAXIMUM_GENERATED_TEXT_BYTES) {
|
||||
throw new RangeError(
|
||||
"Generated C embeds at most 1 MiB of subject text; reduce the fixture without changing the pattern.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.replacement !== undefined &&
|
||||
utf8ByteLength(request.replacement) > 256 * 1024
|
||||
) {
|
||||
throw new RangeError(
|
||||
"PCRE2 generated replacements are limited to 256 KiB.",
|
||||
);
|
||||
}
|
||||
if (request.operation !== "match" && request.operation !== "replace") {
|
||||
throw new Error("Unsupported PCRE2 C generation operation.");
|
||||
}
|
||||
if (request.operation === "replace" && request.replacement === undefined) {
|
||||
throw new Error("Replacement generation requires an exact replacement.");
|
||||
}
|
||||
return {
|
||||
...request,
|
||||
flavourVersion: version.value,
|
||||
flags,
|
||||
options,
|
||||
scanAll: request.scanAll === true,
|
||||
maximumMatches: integer(
|
||||
request.maximumMatches,
|
||||
"Generated match limit",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||
),
|
||||
maximumCaptureRows: integer(
|
||||
request.maximumCaptureRows,
|
||||
"Generated capture-row limit",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumCaptureRows,
|
||||
),
|
||||
maximumOutputBytes: integer(
|
||||
request.maximumOutputBytes,
|
||||
"Generated output limit",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function generatePcre2C(
|
||||
input: Pcre2CGenerationRequest,
|
||||
): GeneratedPcre2CProgram {
|
||||
const request = validate(input);
|
||||
const global = request.scanAll || request.flags.includes("g");
|
||||
const represents =
|
||||
request.operation === "replace"
|
||||
? "replacement"
|
||||
: global
|
||||
? "all-matches"
|
||||
: "first-match";
|
||||
const source =
|
||||
request.operation === "replace"
|
||||
? replacementSource(request, global)
|
||||
: matchSource(request, global);
|
||||
return {
|
||||
target: "c17-pcre2-8",
|
||||
fileName:
|
||||
request.operation === "replace"
|
||||
? "regex-tools-pcre2-replace.c"
|
||||
: "regex-tools-pcre2-match.c",
|
||||
source,
|
||||
compileCommand:
|
||||
"cc -std=c17 -Wall -Wextra -Werror regex-tools-pcre2.c $(pkg-config --cflags --libs libpcre2-8) -o regex-tools-pcre2",
|
||||
engineIdentity: PCRE2_VERSION,
|
||||
represents,
|
||||
caveats: [
|
||||
"PCRE2_UTF and PCRE2_UCP are mandatory, matching the Regex Tools WebAssembly runtime.",
|
||||
global
|
||||
? "The selected g/scan-all behavior is implemented by host iteration (or PCRE2_SUBSTITUTE_GLOBAL), not by a PCRE2 compile flag."
|
||||
: "The program requests only the first match.",
|
||||
request.operation === "replace"
|
||||
? "The output byte cap is a host buffer; PCRE2 has no Regex Tools result-row cap, so replacement row limits are checked only after substitution."
|
||||
: "Match and capture-row caps are host-loop limits, not native PCRE2 options.",
|
||||
"PCRE2 match, depth and heap limits are configured; PCRE2 exposes no native wall-clock timeout, so hard cancellation belongs to the containing process.",
|
||||
"Native ranges printed by the C API are UTF-8 byte offsets; browser editor ranges are normalized separately to UTF-16.",
|
||||
],
|
||||
};
|
||||
}
|
||||
335
src/regex/comparison/ComparisonOrchestrator.test.ts
Normal file
335
src/regex/comparison/ComparisonOrchestrator.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RegexEngineInfo } from "../model/flavour";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexExecutionResult,
|
||||
RegexReplacementRequest,
|
||||
RegexReplacementResult,
|
||||
} from "../model/match";
|
||||
import type {
|
||||
RegexSyntaxRequest,
|
||||
RegexSyntaxResult,
|
||||
ReplacementSyntaxResult,
|
||||
} from "../model/syntax";
|
||||
import { WorkerRequestError } from "../execution/WorkerSupervisor";
|
||||
import {
|
||||
ComparisonOrchestrator,
|
||||
type ComparisonEngineRunner,
|
||||
type ComparisonSyntaxRunner,
|
||||
} from "./ComparisonOrchestrator";
|
||||
import type {
|
||||
ComparisonFlavour,
|
||||
RegexComparisonRequest,
|
||||
} from "./comparison.types";
|
||||
|
||||
function syntax(request: RegexSyntaxRequest): RegexSyntaxResult {
|
||||
return {
|
||||
accepted: true,
|
||||
root: {
|
||||
id: `${request.flavour}-root`,
|
||||
kind: "pattern",
|
||||
range: { startUtf16: 0, endUtf16: request.pattern.length },
|
||||
raw: request.pattern,
|
||||
explanation: "Fixture syntax",
|
||||
children: [],
|
||||
properties: {
|
||||
zeroWidth: false,
|
||||
nullable: "unknown",
|
||||
consumesInput: "conditional",
|
||||
},
|
||||
support: {
|
||||
flavour: request.flavour,
|
||||
status: "partial",
|
||||
notes: [],
|
||||
},
|
||||
provenance: {
|
||||
provider: `${request.flavour}-fixture`,
|
||||
providerVersion: "1",
|
||||
source: "parsed",
|
||||
},
|
||||
},
|
||||
tokens: [],
|
||||
captures: [
|
||||
{
|
||||
number: 1,
|
||||
name: "word",
|
||||
range: { startUtf16: 0, endUtf16: request.pattern.length },
|
||||
repeated: false,
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
provider: { id: `${request.flavour}-fixture`, version: "1" },
|
||||
coverage: {
|
||||
status: "partial",
|
||||
summary: "Fixture",
|
||||
unsupportedConstructs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function engineInfo(flavour: ComparisonFlavour): RegexEngineInfo {
|
||||
return {
|
||||
flavour,
|
||||
adapterVersion: "fixture",
|
||||
engineName:
|
||||
flavour === "ecmascript" ? "Native ECMAScript RegExp" : "PCRE2 WASM",
|
||||
engineVersion: flavour === "ecmascript" ? "fixture-browser" : "10.47",
|
||||
offsetUnit: flavour === "ecmascript" ? "utf16" : "utf8-byte",
|
||||
capabilities: {
|
||||
compilation: true,
|
||||
matching: true,
|
||||
replacement: true,
|
||||
namedCaptures: true,
|
||||
captureHistory: false,
|
||||
actualTrace: false,
|
||||
benchmark: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function execution(request: RegexExecutionRequest): RegexExecutionResult {
|
||||
const nativeEnd = request.flavour === "pcre2" ? 5 : 4;
|
||||
return {
|
||||
accepted: true,
|
||||
engine: engineInfo(request.flavour as ComparisonFlavour),
|
||||
flags: {
|
||||
userFlags: request.flags.join(""),
|
||||
effectiveFlags: request.flags.join(""),
|
||||
internallyAddedIndicesFlag: request.flavour === "ecmascript",
|
||||
internallyAddedGlobalFlag: false,
|
||||
},
|
||||
matches: [
|
||||
{
|
||||
matchNumber: 1,
|
||||
value: "café",
|
||||
valueStatus: "complete",
|
||||
range: { startUtf16: 0, endUtf16: 4 },
|
||||
nativeRange: {
|
||||
start: 0,
|
||||
end: nativeEnd,
|
||||
unit: request.flavour === "pcre2" ? "utf8-byte" : "utf16",
|
||||
},
|
||||
captures: [
|
||||
{
|
||||
groupNumber: 1,
|
||||
groupName: "word",
|
||||
value: "café",
|
||||
status: "participated",
|
||||
range: { startUtf16: 0, endUtf16: 4 },
|
||||
nativeRange: {
|
||||
start: 0,
|
||||
end: nativeEnd,
|
||||
unit: request.flavour === "pcre2" ? "utf8-byte" : "utf16",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
elapsedMs: 1,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function request(
|
||||
operation: "match" | "replace" = "match",
|
||||
): RegexComparisonRequest {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
patternModel: "shared",
|
||||
operation,
|
||||
subject: "café",
|
||||
scanAll: true,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
maximumOutputBytes: 1_024,
|
||||
timeoutMs: 500,
|
||||
sides: [
|
||||
{
|
||||
flavour: "ecmascript",
|
||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
||||
pattern: "(?<word>.+)",
|
||||
flags: ["g", "u"],
|
||||
options: {},
|
||||
...(operation === "replace" ? { replacement: "$<word>!" } : {}),
|
||||
},
|
||||
{
|
||||
flavour: "pcre2",
|
||||
flavourVersion: "PCRE2 10.47 8-bit WebAssembly",
|
||||
pattern: "(?<word>.+)",
|
||||
flags: ["g"],
|
||||
options: {
|
||||
matchLimit: 1_000_000,
|
||||
depthLimit: 1_000,
|
||||
heapLimitKib: 32_768,
|
||||
},
|
||||
...(operation === "replace" ? { replacement: "${word}!" } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function syntaxRunner(
|
||||
parsePattern = async (value: RegexSyntaxRequest) => syntax(value),
|
||||
): ComparisonSyntaxRunner {
|
||||
return {
|
||||
parsePattern,
|
||||
parseReplacement: async (): Promise<ReplacementSyntaxResult> => ({
|
||||
accepted: true,
|
||||
tokens: [],
|
||||
diagnostics: [],
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function engineRunner(
|
||||
execute = async (value: RegexExecutionRequest) => execution(value),
|
||||
): ComparisonEngineRunner {
|
||||
return {
|
||||
execute,
|
||||
replace: async (
|
||||
value: RegexReplacementRequest,
|
||||
): Promise<RegexReplacementResult> => ({
|
||||
execution: await execute(value),
|
||||
output: `${value.subject}!`,
|
||||
outputBytes: value.subject.length + 1,
|
||||
outputTruncated: false,
|
||||
truncated: false,
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("ComparisonOrchestrator", () => {
|
||||
it("retains exact requests and aligns UTF-16 results while preserving native offsets", async () => {
|
||||
let now = 0;
|
||||
const runner = engineRunner();
|
||||
const orchestrator = new ComparisonOrchestrator({
|
||||
createSyntaxRunner: () => syntaxRunner(),
|
||||
createEngineRunner: () => runner,
|
||||
now: () => (now += 1),
|
||||
isoNow: () => "2026-07-26T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await orchestrator.compare(request());
|
||||
|
||||
expect(result.status).toBe("same-for-current-input");
|
||||
expect(result.notComparable).toEqual([]);
|
||||
expect(result.matchAlignments).toHaveLength(1);
|
||||
expect(result.matchAlignments[0]).toMatchObject({
|
||||
alignment: "normalized-range",
|
||||
equal: true,
|
||||
left: {
|
||||
nativeRange: { end: 4, unit: "utf16" },
|
||||
},
|
||||
right: {
|
||||
nativeRange: { end: 5, unit: "utf8-byte" },
|
||||
},
|
||||
});
|
||||
expect(result.differences.map((difference) => difference.kind)).toEqual([
|
||||
"effective-flags",
|
||||
"offset-model",
|
||||
]);
|
||||
expect(result.sides[0].executionRequest).toMatchObject({
|
||||
flavour: "ecmascript",
|
||||
pattern: "(?<word>.+)",
|
||||
subject: "café",
|
||||
captureMetadata: [{ number: 1, name: "word" }],
|
||||
});
|
||||
expect(result.sides[1].requestIdentity).toMatch(/^request-[0-9a-f]{8}$/u);
|
||||
expect(result.notices[0]?.message).toMatch(/not a proof/u);
|
||||
orchestrator.dispose();
|
||||
});
|
||||
|
||||
it("records an independent timeout instead of interpreting it as no match", async () => {
|
||||
const runner = engineRunner(async (value) => {
|
||||
if (value.flavour === "pcre2") {
|
||||
throw new WorkerRequestError("timeout", "PCRE2 exceeded 500 ms");
|
||||
}
|
||||
return execution(value);
|
||||
});
|
||||
const orchestrator = new ComparisonOrchestrator({
|
||||
createSyntaxRunner: () => syntaxRunner(),
|
||||
createEngineRunner: () => runner,
|
||||
now: () => 1,
|
||||
isoNow: () => "2026-07-26T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await orchestrator.compare(request());
|
||||
|
||||
expect(result.status).toBe("not-comparable");
|
||||
expect(result.sides[0].runtime.status).toBe("complete");
|
||||
expect(result.sides[1].runtime.status).toBe("timeout");
|
||||
expect(result.notComparable).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "execution-timeout",
|
||||
flavour: "pcre2",
|
||||
}),
|
||||
);
|
||||
expect(result.notComparable[0]?.message).toMatch(/not a no-match/u);
|
||||
orchestrator.dispose();
|
||||
});
|
||||
|
||||
it("keeps per-flavour replacement templates exact", async () => {
|
||||
const seen: RegexReplacementRequest[] = [];
|
||||
const runner = engineRunner();
|
||||
runner.replace = async (value) => {
|
||||
seen.push(value);
|
||||
return {
|
||||
execution: execution(value),
|
||||
output:
|
||||
value.flavour === "ecmascript"
|
||||
? value.replacement
|
||||
: value.replacement,
|
||||
outputBytes: value.replacement.length,
|
||||
outputTruncated: false,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
const orchestrator = new ComparisonOrchestrator({
|
||||
createSyntaxRunner: () => syntaxRunner(),
|
||||
createEngineRunner: () => runner,
|
||||
now: () => 1,
|
||||
isoNow: () => "2026-07-26T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await orchestrator.compare(request("replace"));
|
||||
|
||||
expect(seen.map((value) => value.replacement)).toEqual([
|
||||
"$<word>!",
|
||||
"${word}!",
|
||||
]);
|
||||
expect(result.status).toBe("different-for-current-input");
|
||||
expect(
|
||||
result.differences.find(
|
||||
(difference) => difference.kind === "replacement-output",
|
||||
),
|
||||
).toMatchObject({
|
||||
left: "$<word>!",
|
||||
right: "${word}!",
|
||||
});
|
||||
orchestrator.dispose();
|
||||
});
|
||||
|
||||
it("refuses mismatched shared patterns before constructing a semantic result", async () => {
|
||||
const input = request();
|
||||
const invalid: RegexComparisonRequest = {
|
||||
...input,
|
||||
sides: [input.sides[0], { ...input.sides[1], pattern: "(?<word>\\w+)" }],
|
||||
};
|
||||
const orchestrator = new ComparisonOrchestrator({
|
||||
createSyntaxRunner: () => syntaxRunner(),
|
||||
createEngineRunner: () => engineRunner(),
|
||||
now: () => 1,
|
||||
isoNow: () => "2026-07-26T12:00:00.000Z",
|
||||
});
|
||||
|
||||
await expect(orchestrator.compare(invalid)).rejects.toThrow(
|
||||
/exact same pattern/u,
|
||||
);
|
||||
orchestrator.dispose();
|
||||
});
|
||||
});
|
||||
536
src/regex/comparison/ComparisonOrchestrator.ts
Normal file
536
src/regex/comparison/ComparisonOrchestrator.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import {
|
||||
AVAILABLE_REGEX_FLAVOURS,
|
||||
parseRegexFlags,
|
||||
parseRegexOptions,
|
||||
resolveRegexFlavourVersion,
|
||||
} from "../flavours/flavour-registry";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../model/match";
|
||||
import type {
|
||||
RegexSyntaxRequest,
|
||||
RegexSyntaxResult,
|
||||
ReplacementSyntaxResult,
|
||||
} from "../model/syntax";
|
||||
import {
|
||||
DEFAULT_REGEX_LIMITS,
|
||||
utf8ByteLength,
|
||||
} from "../execution/request-limits";
|
||||
import { EngineSupervisor } from "../execution/EngineSupervisor";
|
||||
import { SyntaxSupervisor } from "../execution/SyntaxSupervisor";
|
||||
import { WorkerRequestError } from "../execution/WorkerSupervisor";
|
||||
import { compareCompletedResults } from "./compare-results";
|
||||
import type {
|
||||
ComparisonExecutionOutcome,
|
||||
ComparisonFlavour,
|
||||
ComparisonSideInput,
|
||||
ComparisonSideOutcome,
|
||||
ComparisonSyntaxOutcome,
|
||||
ComparisonTaskFailure,
|
||||
RegexComparisonRequest,
|
||||
RegexComparisonResult,
|
||||
} from "./comparison.types";
|
||||
|
||||
export interface ComparisonSyntaxRunner {
|
||||
parsePattern(
|
||||
request: RegexSyntaxRequest,
|
||||
timeoutMs?: number,
|
||||
): Promise<RegexSyntaxResult>;
|
||||
parseReplacement(
|
||||
request: {
|
||||
readonly flavour: ComparisonFlavour;
|
||||
readonly replacement: string;
|
||||
readonly captureMetadata: RegexSyntaxResult["captures"];
|
||||
},
|
||||
timeoutMs?: number,
|
||||
): Promise<ReplacementSyntaxResult>;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface ComparisonEngineRunner {
|
||||
execute(
|
||||
request: RegexExecutionRequest,
|
||||
timeoutMs: number,
|
||||
): ReturnType<EngineSupervisor["execute"]>;
|
||||
replace(
|
||||
request: RegexReplacementRequest,
|
||||
timeoutMs: number,
|
||||
): ReturnType<EngineSupervisor["replace"]>;
|
||||
cancel(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface ComparisonOrchestratorDependencies {
|
||||
readonly createSyntaxRunner: (
|
||||
flavour: ComparisonFlavour,
|
||||
) => ComparisonSyntaxRunner;
|
||||
readonly createEngineRunner: () => ComparisonEngineRunner;
|
||||
readonly now: () => number;
|
||||
readonly isoNow: () => string;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPENDENCIES: ComparisonOrchestratorDependencies = {
|
||||
createSyntaxRunner: (flavour) =>
|
||||
new SyntaxSupervisor(
|
||||
`${flavour} comparison syntax`,
|
||||
`regex-tools-comparison-${flavour}-syntax`,
|
||||
),
|
||||
createEngineRunner: () => new EngineSupervisor(),
|
||||
now: () => performance.now(),
|
||||
isoNow: () => new Date().toISOString(),
|
||||
};
|
||||
|
||||
interface PreparedSide {
|
||||
readonly input: ComparisonSideInput;
|
||||
readonly syntaxRequest: RegexSyntaxRequest;
|
||||
}
|
||||
|
||||
function requireBoundedInteger(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be an integer from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSide(input: ComparisonSideInput): PreparedSide {
|
||||
const definition = AVAILABLE_REGEX_FLAVOURS.require(input.flavour);
|
||||
const version = resolveRegexFlavourVersion(
|
||||
input.flavourVersion,
|
||||
definition,
|
||||
`${definition.label} comparison version`,
|
||||
).definition;
|
||||
const flags = parseRegexFlags(
|
||||
input.flags,
|
||||
definition,
|
||||
`${definition.label} comparison flags`,
|
||||
);
|
||||
const options = parseRegexOptions(
|
||||
input.options,
|
||||
definition,
|
||||
`${definition.label} comparison options`,
|
||||
);
|
||||
if (input.pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
|
||||
throw new RangeError(
|
||||
`${definition.label} comparison pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.replacement !== undefined &&
|
||||
input.replacement.length >
|
||||
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
|
||||
) {
|
||||
throw new RangeError(
|
||||
`${definition.label} comparison replacement exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit.`,
|
||||
);
|
||||
}
|
||||
const normalized: ComparisonSideInput = {
|
||||
flavour: input.flavour,
|
||||
flavourVersion: version.value,
|
||||
pattern: input.pattern,
|
||||
flags,
|
||||
options,
|
||||
...(input.replacement === undefined
|
||||
? {}
|
||||
: { replacement: input.replacement }),
|
||||
};
|
||||
return {
|
||||
input: normalized,
|
||||
syntaxRequest: {
|
||||
flavour: normalized.flavour,
|
||||
flavourVersion: version.syntaxVersion,
|
||||
pattern: normalized.pattern,
|
||||
flags: normalized.flags,
|
||||
options: normalized.options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateRegexComparisonRequest(
|
||||
request: RegexComparisonRequest,
|
||||
): {
|
||||
readonly request: RegexComparisonRequest;
|
||||
readonly sides: readonly [PreparedSide, PreparedSide];
|
||||
} {
|
||||
if (request.schemaVersion !== 1) {
|
||||
throw new Error("Unsupported comparison request schema.");
|
||||
}
|
||||
if (
|
||||
request.patternModel !== "shared" &&
|
||||
request.patternModel !== "variants"
|
||||
) {
|
||||
throw new Error("Comparison pattern model is unsupported.");
|
||||
}
|
||||
if (request.operation !== "match" && request.operation !== "replace") {
|
||||
throw new Error("Comparison operation is unsupported.");
|
||||
}
|
||||
const flavours = request.sides.map((side) => side.flavour);
|
||||
if (
|
||||
new Set(flavours).size !== 2 ||
|
||||
!flavours.includes("ecmascript") ||
|
||||
!flavours.includes("pcre2")
|
||||
) {
|
||||
throw new Error(
|
||||
"This comparison vertical requires one ECMAScript and one PCRE2 side.",
|
||||
);
|
||||
}
|
||||
const preparedInput = request.sides.map(validateSide);
|
||||
const ecmascript = preparedInput.find(
|
||||
(side) => side.input.flavour === "ecmascript",
|
||||
);
|
||||
const pcre2 = preparedInput.find((side) => side.input.flavour === "pcre2");
|
||||
if (!ecmascript || !pcre2) {
|
||||
throw new Error("Comparison sides could not be normalized.");
|
||||
}
|
||||
const prepared = [ecmascript, pcre2] as const;
|
||||
if (
|
||||
request.patternModel === "shared" &&
|
||||
prepared[0].input.pattern !== prepared[1].input.pattern
|
||||
) {
|
||||
throw new Error(
|
||||
"Shared-pattern comparison must send the exact same pattern to both engines.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.operation === "replace" &&
|
||||
prepared.some((side) => side.input.replacement === undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
"Replacement comparison requires an explicit replacement for each flavour.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
utf8ByteLength(request.subject) >
|
||||
DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Comparison subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
|
||||
);
|
||||
}
|
||||
const maximumMatches = requireBoundedInteger(
|
||||
request.maximumMatches,
|
||||
"Comparison match limit",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||
);
|
||||
const maximumCaptureRows = requireBoundedInteger(
|
||||
request.maximumCaptureRows,
|
||||
"Comparison capture-row limit",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumCaptureRows,
|
||||
);
|
||||
const maximumOutputBytes = requireBoundedInteger(
|
||||
request.maximumOutputBytes,
|
||||
"Comparison replacement-output limit",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes,
|
||||
);
|
||||
const timeoutMs = requireBoundedInteger(
|
||||
request.timeoutMs,
|
||||
"Comparison side timeout",
|
||||
1,
|
||||
DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
|
||||
);
|
||||
const normalized: RegexComparisonRequest = {
|
||||
...request,
|
||||
scanAll: request.scanAll === true,
|
||||
maximumMatches,
|
||||
maximumCaptureRows,
|
||||
maximumOutputBytes,
|
||||
timeoutMs,
|
||||
sides: [prepared[0].input, prepared[1].input],
|
||||
};
|
||||
return { request: normalized, sides: prepared };
|
||||
}
|
||||
|
||||
function classifyFailure(error: unknown): ComparisonTaskFailure {
|
||||
if (error instanceof WorkerRequestError) {
|
||||
if (error.kind === "timeout") {
|
||||
return { status: "timeout", message: error.message };
|
||||
}
|
||||
if (error.kind === "cancelled") {
|
||||
return { status: "cancelled", message: error.message };
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "worker-failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
function stableOptions(options: ComparisonSideInput["options"]): string {
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(options).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function requestIdentity(
|
||||
request: RegexExecutionRequest | RegexReplacementRequest,
|
||||
): string {
|
||||
const replacement =
|
||||
"replacement" in request ? `\u0000${request.replacement}` : "";
|
||||
const value = [
|
||||
request.flavour,
|
||||
request.flavourVersion ?? "",
|
||||
request.pattern,
|
||||
request.flags.join(""),
|
||||
stableOptions(request.options ?? {}),
|
||||
request.subject,
|
||||
String(request.scanAll),
|
||||
String(request.maximumMatches),
|
||||
String(request.maximumCaptureRows),
|
||||
JSON.stringify(request.captureMetadata),
|
||||
"maximumOutputBytes" in request ? String(request.maximumOutputBytes) : "",
|
||||
replacement,
|
||||
].join("\u0001");
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return `request-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
function executionRequest(
|
||||
request: RegexComparisonRequest,
|
||||
prepared: PreparedSide,
|
||||
captures: RegexSyntaxResult["captures"],
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: prepared.input.flavour,
|
||||
flavourVersion: prepared.input.flavourVersion,
|
||||
pattern: prepared.input.pattern,
|
||||
flags: prepared.input.flags,
|
||||
options: prepared.input.options,
|
||||
subject: request.subject,
|
||||
captureMetadata: captures,
|
||||
scanAll: request.scanAll,
|
||||
maximumMatches: request.maximumMatches,
|
||||
maximumCaptureRows: request.maximumCaptureRows,
|
||||
};
|
||||
}
|
||||
|
||||
export class ComparisonOrchestrator {
|
||||
readonly #dependencies: ComparisonOrchestratorDependencies;
|
||||
readonly #syntax: ReadonlyMap<ComparisonFlavour, ComparisonSyntaxRunner>;
|
||||
readonly #engine: ComparisonEngineRunner;
|
||||
#generation = 0;
|
||||
#disposed = false;
|
||||
|
||||
constructor(
|
||||
dependencies: ComparisonOrchestratorDependencies = DEFAULT_DEPENDENCIES,
|
||||
) {
|
||||
this.#dependencies = dependencies;
|
||||
this.#syntax = new Map(
|
||||
(["ecmascript", "pcre2"] as const).map((flavour) => [
|
||||
flavour,
|
||||
dependencies.createSyntaxRunner(flavour),
|
||||
]),
|
||||
);
|
||||
this.#engine = dependencies.createEngineRunner();
|
||||
}
|
||||
|
||||
async compare(input: RegexComparisonRequest): Promise<RegexComparisonResult> {
|
||||
if (this.#disposed) {
|
||||
throw new Error("Comparison orchestrator has been disposed.");
|
||||
}
|
||||
this.cancel();
|
||||
const generation = this.#generation;
|
||||
const validated = validateRegexComparisonRequest(input);
|
||||
const startedAt = this.#dependencies.isoNow();
|
||||
const start = this.#dependencies.now();
|
||||
const settled = await Promise.allSettled(
|
||||
validated.sides.map((side) =>
|
||||
this.#runSide(validated.request, side, generation),
|
||||
),
|
||||
);
|
||||
const outcomes = settled.map((entry, index) => {
|
||||
if (entry.status === "fulfilled") return entry.value;
|
||||
const prepared = validated.sides[index];
|
||||
if (!prepared) throw entry.reason;
|
||||
return this.#failedSide(
|
||||
validated.request,
|
||||
prepared,
|
||||
classifyFailure(entry.reason),
|
||||
);
|
||||
}) as unknown as readonly [ComparisonSideOutcome, ComparisonSideOutcome];
|
||||
return compareCompletedResults(
|
||||
validated.request,
|
||||
startedAt,
|
||||
this.#dependencies.now() - start,
|
||||
outcomes,
|
||||
);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.#generation += 1;
|
||||
for (const syntax of this.#syntax.values()) syntax.cancel();
|
||||
this.#engine.cancel();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.cancel();
|
||||
this.#disposed = true;
|
||||
for (const syntax of this.#syntax.values()) syntax.dispose();
|
||||
this.#engine.dispose();
|
||||
}
|
||||
|
||||
async #runSide(
|
||||
request: RegexComparisonRequest,
|
||||
prepared: PreparedSide,
|
||||
generation: number,
|
||||
): Promise<ComparisonSideOutcome> {
|
||||
const syntaxRunner = this.#syntax.get(prepared.input.flavour);
|
||||
if (!syntaxRunner) {
|
||||
throw new Error(`Missing ${prepared.input.flavour} syntax runner.`);
|
||||
}
|
||||
const syntaxStart = this.#dependencies.now();
|
||||
let patternSyntax: RegexSyntaxResult | undefined;
|
||||
let replacementSyntax: ReplacementSyntaxResult | undefined;
|
||||
let syntaxFailure: ComparisonTaskFailure | undefined;
|
||||
try {
|
||||
patternSyntax = await syntaxRunner.parsePattern(
|
||||
prepared.syntaxRequest,
|
||||
Math.min(1_000, request.timeoutMs),
|
||||
);
|
||||
if (request.operation === "replace") {
|
||||
replacementSyntax = await syntaxRunner.parseReplacement(
|
||||
{
|
||||
flavour: prepared.input.flavour,
|
||||
replacement: prepared.input.replacement ?? "",
|
||||
captureMetadata: patternSyntax.captures,
|
||||
},
|
||||
Math.min(1_000, request.timeoutMs),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
syntaxFailure = classifyFailure(error);
|
||||
}
|
||||
const syntax: ComparisonSyntaxOutcome = {
|
||||
status: syntaxFailure?.status ?? "complete",
|
||||
elapsedMs: this.#dependencies.now() - syntaxStart,
|
||||
...(patternSyntax ? { pattern: patternSyntax } : {}),
|
||||
...(replacementSyntax ? { replacement: replacementSyntax } : {}),
|
||||
...(syntaxFailure ? { error: syntaxFailure.message } : {}),
|
||||
};
|
||||
const exactExecution = executionRequest(
|
||||
request,
|
||||
prepared,
|
||||
patternSyntax?.captures ?? [],
|
||||
);
|
||||
const exactReplacement: RegexReplacementRequest | undefined =
|
||||
request.operation === "replace"
|
||||
? {
|
||||
...exactExecution,
|
||||
replacement: prepared.input.replacement ?? "",
|
||||
maximumOutputBytes: request.maximumOutputBytes,
|
||||
}
|
||||
: undefined;
|
||||
if (generation !== this.#generation) {
|
||||
const runtime: ComparisonExecutionOutcome = {
|
||||
status: "cancelled",
|
||||
elapsedMs: 0,
|
||||
error: "Comparison was cancelled before engine execution.",
|
||||
};
|
||||
return {
|
||||
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
|
||||
input: prepared.input,
|
||||
syntaxRequest: prepared.syntaxRequest,
|
||||
executionRequest: exactExecution,
|
||||
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
|
||||
syntax,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeStart = this.#dependencies.now();
|
||||
let runtime: ComparisonExecutionOutcome;
|
||||
try {
|
||||
if (exactReplacement) {
|
||||
const replacement = await this.#engine.replace(
|
||||
exactReplacement,
|
||||
request.timeoutMs,
|
||||
);
|
||||
runtime = {
|
||||
status: "complete",
|
||||
elapsedMs: this.#dependencies.now() - runtimeStart,
|
||||
replacement,
|
||||
};
|
||||
} else {
|
||||
const execution = await this.#engine.execute(
|
||||
exactExecution,
|
||||
request.timeoutMs,
|
||||
);
|
||||
runtime = {
|
||||
status: "complete",
|
||||
elapsedMs: this.#dependencies.now() - runtimeStart,
|
||||
execution,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = classifyFailure(error);
|
||||
runtime = {
|
||||
status: failure.status,
|
||||
elapsedMs: this.#dependencies.now() - runtimeStart,
|
||||
error: failure.message,
|
||||
};
|
||||
}
|
||||
const execution = runtime.replacement?.execution ?? runtime.execution;
|
||||
return {
|
||||
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
|
||||
input: prepared.input,
|
||||
syntaxRequest: prepared.syntaxRequest,
|
||||
executionRequest: exactExecution,
|
||||
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
|
||||
syntax,
|
||||
runtime,
|
||||
...(execution ? { engine: execution.engine } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
#failedSide(
|
||||
request: RegexComparisonRequest,
|
||||
prepared: PreparedSide,
|
||||
failure: ComparisonTaskFailure,
|
||||
): ComparisonSideOutcome {
|
||||
const exactExecution = executionRequest(request, prepared, []);
|
||||
const exactReplacement =
|
||||
request.operation === "replace"
|
||||
? {
|
||||
...exactExecution,
|
||||
replacement: prepared.input.replacement ?? "",
|
||||
maximumOutputBytes: request.maximumOutputBytes,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
requestIdentity: requestIdentity(exactReplacement ?? exactExecution),
|
||||
input: prepared.input,
|
||||
syntaxRequest: prepared.syntaxRequest,
|
||||
executionRequest: exactExecution,
|
||||
...(exactReplacement ? { replacementRequest: exactReplacement } : {}),
|
||||
syntax: {
|
||||
status: "worker-failed",
|
||||
elapsedMs: 0,
|
||||
error: "Comparison side failed before syntax completion.",
|
||||
},
|
||||
runtime: {
|
||||
status: failure.status,
|
||||
elapsedMs: 0,
|
||||
error: failure.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
666
src/regex/comparison/compare-results.ts
Normal file
666
src/regex/comparison/compare-results.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
import type {
|
||||
CaptureResult,
|
||||
RegexExecutionResult,
|
||||
RegexMatchResult,
|
||||
} from "../model/match";
|
||||
import type {
|
||||
CaptureAlignment,
|
||||
ComparisonDifference,
|
||||
ComparisonDifferenceKind,
|
||||
ComparisonFlavour,
|
||||
ComparisonNotComparableReason,
|
||||
ComparisonSideOutcome,
|
||||
MatchAlignment,
|
||||
RegexComparisonRequest,
|
||||
RegexComparisonResult,
|
||||
} from "./comparison.types";
|
||||
|
||||
const MAXIMUM_RETAINED_DIFFERENCES = 5_000;
|
||||
const MAXIMUM_RETAINED_MATCH_ALIGNMENTS = 2_000;
|
||||
const MAXIMUM_DIFFERENCE_VALUE_UTF16 = 1_024;
|
||||
|
||||
function differenceValue(value: string | undefined): string | undefined {
|
||||
if (value === undefined || value.length <= MAXIMUM_DIFFERENCE_VALUE_UTF16) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, MAXIMUM_DIFFERENCE_VALUE_UTF16)}… (${value.length.toLocaleString()} UTF-16 units; complete value retained in its side result)`;
|
||||
}
|
||||
|
||||
class DifferenceCollector {
|
||||
readonly retained: ComparisonDifference[] = [];
|
||||
total = 0;
|
||||
semanticTotal = 0;
|
||||
|
||||
add(
|
||||
kind: ComparisonDifferenceKind,
|
||||
summary: string,
|
||||
left?: string,
|
||||
right?: string,
|
||||
): void {
|
||||
this.total += 1;
|
||||
if (kind !== "effective-flags" && kind !== "offset-model") {
|
||||
this.semanticTotal += 1;
|
||||
}
|
||||
if (this.retained.length >= MAXIMUM_RETAINED_DIFFERENCES) return;
|
||||
this.retained.push({
|
||||
kind,
|
||||
summary,
|
||||
...(left === undefined ? {} : { left }),
|
||||
...(right === undefined ? {} : { right }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rangeKey(
|
||||
value:
|
||||
| {
|
||||
readonly range?: {
|
||||
readonly startUtf16: number;
|
||||
readonly endUtf16: number;
|
||||
};
|
||||
}
|
||||
| undefined,
|
||||
): string | undefined {
|
||||
const range = value?.range;
|
||||
return range ? `${range.startUtf16}:${range.endUtf16}` : undefined;
|
||||
}
|
||||
|
||||
function displayRange(
|
||||
value:
|
||||
| {
|
||||
readonly range?: {
|
||||
readonly startUtf16: number;
|
||||
readonly endUtf16: number;
|
||||
};
|
||||
}
|
||||
| undefined,
|
||||
): string {
|
||||
const range = value?.range;
|
||||
return range ? `${range.startUtf16}–${range.endUtf16}` : "unavailable";
|
||||
}
|
||||
|
||||
function captureIdentity(capture: CaptureResult): string {
|
||||
return capture.groupName
|
||||
? `name:${capture.groupName}`
|
||||
: `number:${capture.groupNumber}`;
|
||||
}
|
||||
|
||||
function takeMatching<T>(
|
||||
values: readonly T[],
|
||||
used: Set<number>,
|
||||
predicate: (value: T) => boolean,
|
||||
): { readonly value: T; readonly index: number } | undefined {
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (used.has(index)) continue;
|
||||
const value = values[index];
|
||||
if (value !== undefined && predicate(value)) return { value, index };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function captureEqual(left: CaptureResult, right: CaptureResult): boolean {
|
||||
return (
|
||||
left.groupNumber === right.groupNumber &&
|
||||
left.groupName === right.groupName &&
|
||||
left.status === right.status &&
|
||||
rangeKey(left) === rangeKey(right) &&
|
||||
left.value === right.value
|
||||
);
|
||||
}
|
||||
|
||||
function compareCapture(
|
||||
left: CaptureResult | undefined,
|
||||
right: CaptureResult | undefined,
|
||||
alignment: CaptureAlignment["alignment"],
|
||||
differences: DifferenceCollector,
|
||||
): CaptureAlignment {
|
||||
if (!left || !right) {
|
||||
differences.add(
|
||||
"capture-presence",
|
||||
"A capture is present on only one side.",
|
||||
left ? captureIdentity(left) : "absent",
|
||||
right ? captureIdentity(right) : "absent",
|
||||
);
|
||||
return {
|
||||
alignment,
|
||||
...(left ? { left } : {}),
|
||||
...(right ? { right } : {}),
|
||||
equal: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
left.groupNumber !== right.groupNumber ||
|
||||
left.groupName !== right.groupName
|
||||
) {
|
||||
differences.add(
|
||||
"capture-identity",
|
||||
"Aligned captures have different group identities.",
|
||||
captureIdentity(left),
|
||||
captureIdentity(right),
|
||||
);
|
||||
}
|
||||
if (left.status !== right.status) {
|
||||
differences.add(
|
||||
"capture-status",
|
||||
"Aligned captures have different participation states.",
|
||||
left.status,
|
||||
right.status,
|
||||
);
|
||||
}
|
||||
if (rangeKey(left) !== rangeKey(right)) {
|
||||
differences.add(
|
||||
"capture-range",
|
||||
"Aligned captures have different normalized UTF-16 ranges.",
|
||||
displayRange(left),
|
||||
displayRange(right),
|
||||
);
|
||||
}
|
||||
if (left.value !== right.value) {
|
||||
differences.add(
|
||||
"capture-value",
|
||||
"Aligned captures have different retained values.",
|
||||
differenceValue(left.value) ?? "unavailable",
|
||||
differenceValue(right.value) ?? "unavailable",
|
||||
);
|
||||
}
|
||||
return {
|
||||
alignment,
|
||||
left,
|
||||
right,
|
||||
equal: captureEqual(left, right),
|
||||
};
|
||||
}
|
||||
|
||||
function alignCaptures(
|
||||
left: readonly CaptureResult[],
|
||||
right: readonly CaptureResult[],
|
||||
differences: DifferenceCollector,
|
||||
): readonly CaptureAlignment[] {
|
||||
const alignments: CaptureAlignment[] = [];
|
||||
const usedRight = new Set<number>();
|
||||
const deferredLeft: CaptureResult[] = [];
|
||||
|
||||
for (const capture of left) {
|
||||
const key = rangeKey(capture);
|
||||
const sameRange = key
|
||||
? (takeMatching(
|
||||
right,
|
||||
usedRight,
|
||||
(candidate) =>
|
||||
rangeKey(candidate) === key &&
|
||||
captureIdentity(candidate) === captureIdentity(capture),
|
||||
) ??
|
||||
takeMatching(
|
||||
right,
|
||||
usedRight,
|
||||
(candidate) => rangeKey(candidate) === key,
|
||||
))
|
||||
: undefined;
|
||||
if (!sameRange) {
|
||||
deferredLeft.push(capture);
|
||||
continue;
|
||||
}
|
||||
usedRight.add(sameRange.index);
|
||||
alignments.push(
|
||||
compareCapture(capture, sameRange.value, "normalized-range", differences),
|
||||
);
|
||||
}
|
||||
|
||||
const stillDeferred: CaptureResult[] = [];
|
||||
for (const capture of deferredLeft) {
|
||||
const sameIdentity = takeMatching(
|
||||
right,
|
||||
usedRight,
|
||||
(candidate) => captureIdentity(candidate) === captureIdentity(capture),
|
||||
);
|
||||
if (!sameIdentity) {
|
||||
stillDeferred.push(capture);
|
||||
continue;
|
||||
}
|
||||
usedRight.add(sameIdentity.index);
|
||||
alignments.push(
|
||||
compareCapture(
|
||||
capture,
|
||||
sameIdentity.value,
|
||||
"capture-identity",
|
||||
differences,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const remainingRight = right
|
||||
.map((value, index) => ({ value, index }))
|
||||
.filter(({ index }) => !usedRight.has(index));
|
||||
const paired = Math.min(stillDeferred.length, remainingRight.length);
|
||||
for (let index = 0; index < paired; index += 1) {
|
||||
const leftCapture = stillDeferred[index];
|
||||
const rightCapture = remainingRight[index];
|
||||
if (!leftCapture || !rightCapture) continue;
|
||||
usedRight.add(rightCapture.index);
|
||||
alignments.push(
|
||||
compareCapture(
|
||||
leftCapture,
|
||||
rightCapture.value,
|
||||
"ordinal-fallback",
|
||||
differences,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const capture of stillDeferred.slice(paired)) {
|
||||
alignments.push(
|
||||
compareCapture(capture, undefined, "left-only", differences),
|
||||
);
|
||||
}
|
||||
for (const { value, index } of remainingRight.slice(paired)) {
|
||||
usedRight.add(index);
|
||||
alignments.push(
|
||||
compareCapture(undefined, value, "right-only", differences),
|
||||
);
|
||||
}
|
||||
return alignments;
|
||||
}
|
||||
|
||||
function matchEqual(
|
||||
left: RegexMatchResult,
|
||||
right: RegexMatchResult,
|
||||
captures: readonly CaptureAlignment[],
|
||||
): boolean {
|
||||
return (
|
||||
rangeKey(left) === rangeKey(right) &&
|
||||
left.value === right.value &&
|
||||
left.valueStatus === right.valueStatus &&
|
||||
captures.every((capture) => capture.equal)
|
||||
);
|
||||
}
|
||||
|
||||
function compareMatch(
|
||||
left: RegexMatchResult | undefined,
|
||||
right: RegexMatchResult | undefined,
|
||||
alignment: MatchAlignment["alignment"],
|
||||
differences: DifferenceCollector,
|
||||
): MatchAlignment {
|
||||
if (!left || !right) {
|
||||
differences.add(
|
||||
"match-presence",
|
||||
"A match is present on only one side.",
|
||||
left ? `match ${left.matchNumber} at ${displayRange(left)}` : "absent",
|
||||
right ? `match ${right.matchNumber} at ${displayRange(right)}` : "absent",
|
||||
);
|
||||
return {
|
||||
alignment,
|
||||
...(left ? { left } : {}),
|
||||
...(right ? { right } : {}),
|
||||
captures: [],
|
||||
equal: false,
|
||||
};
|
||||
}
|
||||
if (rangeKey(left) !== rangeKey(right)) {
|
||||
differences.add(
|
||||
"match-range",
|
||||
"Aligned matches have different normalized UTF-16 ranges.",
|
||||
displayRange(left),
|
||||
displayRange(right),
|
||||
);
|
||||
}
|
||||
if (left.value !== right.value || left.valueStatus !== right.valueStatus) {
|
||||
differences.add(
|
||||
"match-value",
|
||||
"Aligned matches have different retained values.",
|
||||
differenceValue(left.value),
|
||||
differenceValue(right.value),
|
||||
);
|
||||
}
|
||||
const captures = alignCaptures(left.captures, right.captures, differences);
|
||||
return {
|
||||
alignment,
|
||||
left,
|
||||
right,
|
||||
captures,
|
||||
equal: matchEqual(left, right, captures),
|
||||
};
|
||||
}
|
||||
|
||||
function alignMatches(
|
||||
left: readonly RegexMatchResult[],
|
||||
right: readonly RegexMatchResult[],
|
||||
differences: DifferenceCollector,
|
||||
): readonly MatchAlignment[] {
|
||||
const alignments: MatchAlignment[] = [];
|
||||
const usedRight = new Set<number>();
|
||||
const deferredLeft: RegexMatchResult[] = [];
|
||||
|
||||
for (const match of left) {
|
||||
const key = rangeKey(match);
|
||||
const sameRange = takeMatching(
|
||||
right,
|
||||
usedRight,
|
||||
(candidate) => rangeKey(candidate) === key,
|
||||
);
|
||||
if (!sameRange) {
|
||||
deferredLeft.push(match);
|
||||
continue;
|
||||
}
|
||||
usedRight.add(sameRange.index);
|
||||
alignments.push(
|
||||
compareMatch(match, sameRange.value, "normalized-range", differences),
|
||||
);
|
||||
}
|
||||
|
||||
const remainingRight = right
|
||||
.map((value, index) => ({ value, index }))
|
||||
.filter(({ index }) => !usedRight.has(index));
|
||||
const paired = Math.min(deferredLeft.length, remainingRight.length);
|
||||
for (let index = 0; index < paired; index += 1) {
|
||||
const leftMatch = deferredLeft[index];
|
||||
const rightMatch = remainingRight[index];
|
||||
if (!leftMatch || !rightMatch) continue;
|
||||
usedRight.add(rightMatch.index);
|
||||
alignments.push(
|
||||
compareMatch(
|
||||
leftMatch,
|
||||
rightMatch.value,
|
||||
"ordinal-fallback",
|
||||
differences,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const match of deferredLeft.slice(paired)) {
|
||||
alignments.push(compareMatch(match, undefined, "left-only", differences));
|
||||
}
|
||||
for (const { value, index } of remainingRight.slice(paired)) {
|
||||
usedRight.add(index);
|
||||
alignments.push(compareMatch(undefined, value, "right-only", differences));
|
||||
}
|
||||
return alignments;
|
||||
}
|
||||
|
||||
function executionFor(
|
||||
side: ComparisonSideOutcome,
|
||||
): RegexExecutionResult | undefined {
|
||||
return side.runtime.replacement?.execution ?? side.runtime.execution;
|
||||
}
|
||||
|
||||
function addReason(
|
||||
values: ComparisonNotComparableReason[],
|
||||
reason: ComparisonNotComparableReason,
|
||||
): void {
|
||||
if (
|
||||
values.some(
|
||||
(candidate) =>
|
||||
candidate.code === reason.code &&
|
||||
candidate.flavour === reason.flavour &&
|
||||
candidate.message === reason.message,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
values.push(reason);
|
||||
}
|
||||
|
||||
function taskReasons(
|
||||
side: ComparisonSideOutcome,
|
||||
reasons: ComparisonNotComparableReason[],
|
||||
): void {
|
||||
const flavour = side.input.flavour;
|
||||
if (side.syntax.status === "timeout") {
|
||||
addReason(reasons, {
|
||||
code: "syntax-timeout",
|
||||
flavour,
|
||||
message: `${flavour} syntax parsing timed out.`,
|
||||
});
|
||||
} else if (side.syntax.status !== "complete") {
|
||||
addReason(reasons, {
|
||||
code: "syntax-worker-failed",
|
||||
flavour,
|
||||
message: `${flavour} syntax parsing did not complete: ${side.syntax.error ?? side.syntax.status}.`,
|
||||
});
|
||||
} else if (
|
||||
side.syntax.pattern?.accepted === false ||
|
||||
side.syntax.replacement?.accepted === false
|
||||
) {
|
||||
addReason(reasons, {
|
||||
code: "syntax-rejected",
|
||||
flavour,
|
||||
message: `${flavour} syntax provider rejected the exact pattern or replacement snapshot.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (side.runtime.status === "timeout") {
|
||||
addReason(reasons, {
|
||||
code: "execution-timeout",
|
||||
flavour,
|
||||
message: `${flavour} execution timed out; timeout is not a no-match result.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (side.runtime.status === "cancelled") {
|
||||
addReason(reasons, {
|
||||
code: "execution-cancelled",
|
||||
flavour,
|
||||
message: `${flavour} execution was cancelled and has no semantic result.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (side.runtime.status !== "complete") {
|
||||
addReason(reasons, {
|
||||
code: "execution-worker-failed",
|
||||
flavour,
|
||||
message: `${flavour} worker did not complete: ${side.runtime.error ?? side.runtime.status}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const execution = executionFor(side);
|
||||
if (!execution?.accepted) {
|
||||
addReason(reasons, {
|
||||
code: "compile-rejected",
|
||||
flavour,
|
||||
message: `${flavour} engine rejected the exact pattern snapshot.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (execution.truncated) {
|
||||
addReason(reasons, {
|
||||
code: "result-truncated",
|
||||
flavour,
|
||||
message: `${flavour} results reached a configured match or capture limit.`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
execution.matches.some(
|
||||
(match) =>
|
||||
match.valueStatus === "truncated" ||
|
||||
match.captures.some((capture) => capture.status === "truncated"),
|
||||
)
|
||||
) {
|
||||
addReason(reasons, {
|
||||
code: "value-truncated",
|
||||
flavour,
|
||||
message: `${flavour} retained value previews are incomplete.`,
|
||||
});
|
||||
}
|
||||
if (side.runtime.replacement?.truncated) {
|
||||
addReason(reasons, {
|
||||
code: "replacement-truncated",
|
||||
flavour,
|
||||
message: `${flavour} replacement output or its match collection is incomplete.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function compareMetadata(
|
||||
left: ComparisonSideOutcome,
|
||||
right: ComparisonSideOutcome,
|
||||
differences: DifferenceCollector,
|
||||
): void {
|
||||
const leftSyntax = left.syntax.pattern;
|
||||
const rightSyntax = right.syntax.pattern;
|
||||
if (
|
||||
leftSyntax &&
|
||||
rightSyntax &&
|
||||
leftSyntax.accepted !== rightSyntax.accepted
|
||||
) {
|
||||
differences.add(
|
||||
"syntax-acceptance",
|
||||
"The syntax providers disagree on acceptance.",
|
||||
leftSyntax.accepted ? "accepted" : "rejected",
|
||||
rightSyntax.accepted ? "accepted" : "rejected",
|
||||
);
|
||||
}
|
||||
const leftExecution = executionFor(left);
|
||||
const rightExecution = executionFor(right);
|
||||
if (
|
||||
leftExecution &&
|
||||
rightExecution &&
|
||||
leftExecution.accepted !== rightExecution.accepted
|
||||
) {
|
||||
differences.add(
|
||||
"compile-acceptance",
|
||||
"The execution engines disagree on compilation.",
|
||||
leftExecution.accepted ? "accepted" : "rejected",
|
||||
rightExecution.accepted ? "accepted" : "rejected",
|
||||
);
|
||||
}
|
||||
if (
|
||||
leftExecution &&
|
||||
rightExecution &&
|
||||
leftExecution.flags.effectiveFlags !== rightExecution.flags.effectiveFlags
|
||||
) {
|
||||
differences.add(
|
||||
"effective-flags",
|
||||
"The exact effective engine flags differ.",
|
||||
leftExecution.flags.effectiveFlags || "none",
|
||||
rightExecution.flags.effectiveFlags || "none",
|
||||
);
|
||||
}
|
||||
if (
|
||||
leftExecution &&
|
||||
rightExecution &&
|
||||
leftExecution.engine.offsetUnit !== rightExecution.engine.offsetUnit
|
||||
) {
|
||||
differences.add(
|
||||
"offset-model",
|
||||
"Native offset units differ; alignments use normalized editor UTF-16 ranges.",
|
||||
leftExecution.engine.offsetUnit,
|
||||
rightExecution.engine.offsetUnit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function compareCompletedResults(
|
||||
request: RegexComparisonRequest,
|
||||
startedAt: string,
|
||||
elapsedMs: number,
|
||||
sides: readonly [ComparisonSideOutcome, ComparisonSideOutcome],
|
||||
): RegexComparisonResult {
|
||||
const [left, right] = sides;
|
||||
const differences = new DifferenceCollector();
|
||||
const notComparable: ComparisonNotComparableReason[] = [];
|
||||
taskReasons(left, notComparable);
|
||||
taskReasons(right, notComparable);
|
||||
compareMetadata(left, right, differences);
|
||||
|
||||
const leftExecution = executionFor(left);
|
||||
const rightExecution = executionFor(right);
|
||||
let allAlignments: readonly MatchAlignment[] = [];
|
||||
if (leftExecution?.accepted && rightExecution?.accepted) {
|
||||
if (leftExecution.matches.length !== rightExecution.matches.length) {
|
||||
differences.add(
|
||||
"match-count",
|
||||
"The engines returned different match counts.",
|
||||
leftExecution.matches.length.toLocaleString(),
|
||||
rightExecution.matches.length.toLocaleString(),
|
||||
);
|
||||
}
|
||||
allAlignments = alignMatches(
|
||||
leftExecution.matches,
|
||||
rightExecution.matches,
|
||||
differences,
|
||||
);
|
||||
const leftReplacement = left.runtime.replacement;
|
||||
const rightReplacement = right.runtime.replacement;
|
||||
if (
|
||||
leftReplacement &&
|
||||
rightReplacement &&
|
||||
leftReplacement.output !== rightReplacement.output
|
||||
) {
|
||||
differences.add(
|
||||
"replacement-output",
|
||||
"The engine-native replacement outputs differ.",
|
||||
differenceValue(leftReplacement.output),
|
||||
differenceValue(rightReplacement.output),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const syntaxAcceptanceDiffers =
|
||||
left.syntax.pattern !== undefined &&
|
||||
right.syntax.pattern !== undefined &&
|
||||
left.syntax.pattern.accepted !== right.syntax.pattern.accepted;
|
||||
const compileAcceptanceDiffers =
|
||||
leftExecution !== undefined &&
|
||||
rightExecution !== undefined &&
|
||||
leftExecution.accepted !== rightExecution.accepted;
|
||||
if (syntaxAcceptanceDiffers || compileAcceptanceDiffers) {
|
||||
addReason(notComparable, {
|
||||
code: "flavour-specific-syntax",
|
||||
message:
|
||||
"At least one exact snapshot is accepted on only one side; no runtime equivalence claim is possible.",
|
||||
});
|
||||
}
|
||||
|
||||
const status =
|
||||
notComparable.length > 0
|
||||
? "not-comparable"
|
||||
: differences.semanticTotal === 0
|
||||
? "same-for-current-input"
|
||||
: "different-for-current-input";
|
||||
const notices =
|
||||
status === "same-for-current-input"
|
||||
? [
|
||||
{
|
||||
confidence: "likely-equivalent-for-input" as const,
|
||||
message:
|
||||
"The completed, untruncated results agree for this subject only. This is test evidence, not a proof that the patterns are equivalent.",
|
||||
},
|
||||
]
|
||||
: status === "different-for-current-input"
|
||||
? [
|
||||
{
|
||||
confidence: "requires-manual-review" as const,
|
||||
message:
|
||||
"The exact engine results differ for this subject. Review syntax, flags, ranges and captures before porting.",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
confidence:
|
||||
compileAcceptanceDiffers || syntaxAcceptanceDiffers
|
||||
? ("no-direct-equivalent" as const)
|
||||
: ("requires-manual-review" as const),
|
||||
message:
|
||||
"The run is not comparable because one or more authoritative results are incomplete or rejected.",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
request,
|
||||
startedAt,
|
||||
elapsedMs,
|
||||
sides,
|
||||
status,
|
||||
notComparable,
|
||||
differences: differences.retained,
|
||||
totalDifferences: differences.total,
|
||||
differencesTruncated: differences.retained.length < differences.total,
|
||||
matchAlignments: allAlignments.slice(0, MAXIMUM_RETAINED_MATCH_ALIGNMENTS),
|
||||
totalMatchAlignments: allAlignments.length,
|
||||
matchAlignmentsTruncated:
|
||||
allAlignments.length > MAXIMUM_RETAINED_MATCH_ALIGNMENTS,
|
||||
notices,
|
||||
};
|
||||
}
|
||||
|
||||
export function comparisonSideLabel(flavour: ComparisonFlavour): string {
|
||||
return flavour === "ecmascript" ? "ECMAScript" : "PCRE2";
|
||||
}
|
||||
174
src/regex/comparison/comparison.types.ts
Normal file
174
src/regex/comparison/comparison.types.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { RegexEngineInfo, RegexEngineOptions } from "../model/flavour";
|
||||
import type {
|
||||
CaptureResult,
|
||||
RegexExecutionRequest,
|
||||
RegexExecutionResult,
|
||||
RegexMatchResult,
|
||||
RegexReplacementRequest,
|
||||
RegexReplacementResult,
|
||||
} from "../model/match";
|
||||
import type {
|
||||
RegexSyntaxRequest,
|
||||
RegexSyntaxResult,
|
||||
ReplacementSyntaxResult,
|
||||
} from "../model/syntax";
|
||||
|
||||
export type ComparisonFlavour = "ecmascript" | "pcre2";
|
||||
export type ComparisonOperation = "match" | "replace";
|
||||
export type ComparisonPatternModel = "shared" | "variants";
|
||||
|
||||
export interface ComparisonSideInput {
|
||||
readonly flavour: ComparisonFlavour;
|
||||
readonly flavourVersion: string;
|
||||
readonly pattern: string;
|
||||
readonly flags: readonly string[];
|
||||
readonly options: RegexEngineOptions;
|
||||
readonly replacement?: string;
|
||||
}
|
||||
|
||||
export interface RegexComparisonRequest {
|
||||
readonly schemaVersion: 1;
|
||||
readonly patternModel: ComparisonPatternModel;
|
||||
readonly operation: ComparisonOperation;
|
||||
readonly subject: string;
|
||||
readonly scanAll: boolean;
|
||||
readonly maximumMatches: number;
|
||||
readonly maximumCaptureRows: number;
|
||||
readonly maximumOutputBytes: number;
|
||||
readonly timeoutMs: number;
|
||||
readonly sides: readonly [ComparisonSideInput, ComparisonSideInput];
|
||||
}
|
||||
|
||||
export type ComparisonTaskStatus =
|
||||
"complete" | "timeout" | "cancelled" | "worker-failed";
|
||||
|
||||
export interface ComparisonTaskFailure {
|
||||
readonly status: Exclude<ComparisonTaskStatus, "complete">;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface ComparisonSyntaxOutcome {
|
||||
readonly status: ComparisonTaskStatus;
|
||||
readonly elapsedMs: number;
|
||||
readonly pattern?: RegexSyntaxResult;
|
||||
readonly replacement?: ReplacementSyntaxResult;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface ComparisonExecutionOutcome {
|
||||
readonly status: ComparisonTaskStatus;
|
||||
readonly elapsedMs: number;
|
||||
readonly execution?: RegexExecutionResult;
|
||||
readonly replacement?: RegexReplacementResult;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface ComparisonSideOutcome {
|
||||
/**
|
||||
* A deterministic display key for the exact request. It is not a
|
||||
* cryptographic digest; the complete request sent to the worker is retained
|
||||
* below and remains authoritative.
|
||||
*/
|
||||
readonly requestIdentity: string;
|
||||
readonly input: ComparisonSideInput;
|
||||
readonly syntaxRequest: RegexSyntaxRequest;
|
||||
readonly executionRequest: RegexExecutionRequest;
|
||||
readonly replacementRequest?: RegexReplacementRequest;
|
||||
readonly syntax: ComparisonSyntaxOutcome;
|
||||
readonly runtime: ComparisonExecutionOutcome;
|
||||
readonly engine?: RegexEngineInfo;
|
||||
}
|
||||
|
||||
export type ComparisonNotComparableCode =
|
||||
| "syntax-timeout"
|
||||
| "syntax-worker-failed"
|
||||
| "syntax-rejected"
|
||||
| "flavour-specific-syntax"
|
||||
| "execution-timeout"
|
||||
| "execution-cancelled"
|
||||
| "execution-worker-failed"
|
||||
| "compile-rejected"
|
||||
| "result-truncated"
|
||||
| "replacement-truncated"
|
||||
| "value-truncated";
|
||||
|
||||
export interface ComparisonNotComparableReason {
|
||||
readonly code: ComparisonNotComparableCode;
|
||||
readonly flavour?: ComparisonFlavour;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type ComparisonDifferenceKind =
|
||||
| "syntax-acceptance"
|
||||
| "compile-acceptance"
|
||||
| "effective-flags"
|
||||
| "offset-model"
|
||||
| "match-count"
|
||||
| "match-presence"
|
||||
| "match-range"
|
||||
| "match-value"
|
||||
| "capture-presence"
|
||||
| "capture-identity"
|
||||
| "capture-status"
|
||||
| "capture-range"
|
||||
| "capture-value"
|
||||
| "replacement-output";
|
||||
|
||||
export interface ComparisonDifference {
|
||||
readonly kind: ComparisonDifferenceKind;
|
||||
readonly summary: string;
|
||||
readonly left?: string;
|
||||
readonly right?: string;
|
||||
}
|
||||
|
||||
export type MatchAlignmentKind =
|
||||
"normalized-range" | "ordinal-fallback" | "left-only" | "right-only";
|
||||
|
||||
export type CaptureAlignmentKind =
|
||||
| "normalized-range"
|
||||
| "capture-identity"
|
||||
| "ordinal-fallback"
|
||||
| "left-only"
|
||||
| "right-only";
|
||||
|
||||
export interface CaptureAlignment {
|
||||
readonly alignment: CaptureAlignmentKind;
|
||||
readonly left?: CaptureResult;
|
||||
readonly right?: CaptureResult;
|
||||
readonly equal: boolean;
|
||||
}
|
||||
|
||||
export interface MatchAlignment {
|
||||
readonly alignment: MatchAlignmentKind;
|
||||
readonly left?: RegexMatchResult;
|
||||
readonly right?: RegexMatchResult;
|
||||
readonly captures: readonly CaptureAlignment[];
|
||||
readonly equal: boolean;
|
||||
}
|
||||
|
||||
export interface ComparisonNotice {
|
||||
readonly confidence:
|
||||
| "possible-port"
|
||||
| "likely-equivalent-for-input"
|
||||
| "requires-manual-review"
|
||||
| "no-direct-equivalent";
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface RegexComparisonResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly request: RegexComparisonRequest;
|
||||
readonly startedAt: string;
|
||||
readonly elapsedMs: number;
|
||||
readonly sides: readonly [ComparisonSideOutcome, ComparisonSideOutcome];
|
||||
readonly status:
|
||||
"not-comparable" | "same-for-current-input" | "different-for-current-input";
|
||||
readonly notComparable: readonly ComparisonNotComparableReason[];
|
||||
readonly differences: readonly ComparisonDifference[];
|
||||
readonly totalDifferences: number;
|
||||
readonly differencesTruncated: boolean;
|
||||
readonly matchAlignments: readonly MatchAlignment[];
|
||||
readonly totalMatchAlignments: number;
|
||||
readonly matchAlignmentsTruncated: boolean;
|
||||
readonly notices: readonly ComparisonNotice[];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user