Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4951c966d4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
|
.engine-build/
|
||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
@@ -9,4 +10,3 @@ test-results/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.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
|
# 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
|
## 0.1.0 — 2026-07-24
|
||||||
|
|
||||||
- Added the open-source ECMAScript 2025 syntax provider using regexpp 4.12.2.
|
- 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.
|
||||||
241
README.md
241
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,
|
browser. There is no account, backend, upload, remote execution, telemetry,
|
||||||
runtime CDN, remote font, or cloud-save service.
|
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
|
- Syntax acceptance: `@eslint-community/regexpp` 4.12.2, ECMAScript 2025
|
||||||
grammar; application-owned structural explanations are partial and
|
grammar; application-owned structural explanations are partial and
|
||||||
feature-matrix tested.
|
feature-matrix tested.
|
||||||
- Execution engine: the current browser's native ECMAScript `RegExp`.
|
- Execution engine: the current browser's native ECMAScript `RegExp`.
|
||||||
- Native and editor offsets: UTF-16 code units.
|
- 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.
|
- Parsing and execution run in separate workers.
|
||||||
- Actual execution can be terminated by killing its worker.
|
- Actual execution can be terminated by killing its worker.
|
||||||
- The worker is recreated after timeout, crash or cancellation.
|
- The worker is recreated after timeout, crash or cancellation.
|
||||||
- Capture ranges use the engine's `d` indices result.
|
- Capture ranges use the engine's `d` indices result.
|
||||||
- Named, numbered, optional, empty and repeated-final captures are represented.
|
- 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.
|
- Project JSON import/export and optional IndexedDB save are available.
|
||||||
- Interactive test text is not persisted by default.
|
- 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
|
ECMAScript exposes only the final retained capture of a repeated group; Regex
|
||||||
Tools says so and never invents capture history.
|
Tools says so and never invents capture history.
|
||||||
|
|
||||||
Browser ECMAScript engines do not expose their internal backtracking trace.
|
Browser ECMAScript exposes no actual engine trace. PCRE2 has a separate bounded
|
||||||
Version 0.1.0 therefore presents no ECMAScript execution trace and no
|
automatic-callout viewer. Its event fields are reported by PCRE2, while
|
||||||
educational trace disguised as one.
|
“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
|
## Workbench modes
|
||||||
|
|
||||||
- **Match** — live highlighting, extraction tree and bounded capture table.
|
- **Match** — live highlighting, extraction tree and bounded capture table.
|
||||||
- **Replace** — native ECMAScript `RegExp` matches with bounded,
|
- **Replace** — engine-native bounded substitution for ECMAScript and PCRE2;
|
||||||
specification-compatible string substitution; output completeness is
|
output completeness is reported separately from bounded per-match
|
||||||
reported separately from bounded per-match original/result, changed-range and
|
original/result, changed-range and token-contribution previews that
|
||||||
token-contribution previews that synchronize the replacement, pattern,
|
synchronize the replacement, pattern, subject and output editors.
|
||||||
subject and output editors.
|
|
||||||
- **List** — non-executable typed templates with text, CSV, JSON and NDJSON
|
- **List** — non-executable typed templates with text, CSV, JSON and NDJSON
|
||||||
export; export is disabled when engine collection or bounded rendering is
|
export; export is disabled when engine collection or bounded rendering is
|
||||||
incomplete.
|
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,
|
- **Unit tests** — editable and cloneable versioned presence, count, full-match,
|
||||||
capture, replacement, timing and expected-timeout assertions. Capture a
|
capture, replacement, timing and expected-timeout assertions. Capture a
|
||||||
complete current result, select only cases to rerun, filter failures, and
|
complete current result, select only cases to rerun, filter failures, and
|
||||||
import/export a standalone validated JSON suite; execution stays in isolated
|
import/export a standalone validated JSON suite; execution stays in isolated
|
||||||
workers.
|
workers.
|
||||||
|
|
||||||
Corpus, comparison, debugging, analysis, generation, minimization, formatting
|
The auxiliary **PCRE trace**, ECMAScript **Analysis**, **Compare & code**,
|
||||||
and code generation remain roadmap items. Controls for them are not exposed in
|
**Generate cases**, **Minimize** and **Format** workspaces are separate from
|
||||||
this release.
|
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
|
## Flags and iteration
|
||||||
|
|
||||||
Pattern and flags are stored separately; delimiters are presentation only.
|
Pattern and flags are stored separately; delimiters are presentation only.
|
||||||
Native `g` and `y` behavior is preserved. The optional **Scan all** action is
|
ECMAScript preserves native `g` and `y`; PCRE2 offers the application-level
|
||||||
explicit. When neither `g` nor `y` is present it adds `g` only to that execution
|
`g` iteration flag plus `i`, `m`, `s`, `x`, `U` and `J`. PCRE2 UTF/UCP mode is
|
||||||
request, visibly records the internal flag, and does not change the saved user
|
mandatory because browser strings are Unicode and partial-byte ranges cannot be
|
||||||
flags. The `d` flag may likewise be added internally to obtain exact indices
|
represented safely in the editor. The optional **Scan all** action adds only an
|
||||||
without changing matching semantics.
|
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
|
Zero-length global iteration advances using the selected Unicode semantics and
|
||||||
cannot loop forever.
|
cannot loop forever.
|
||||||
@@ -81,29 +133,63 @@ cannot loop forever.
|
|||||||
|
|
||||||
Important defaults:
|
Important defaults:
|
||||||
|
|
||||||
| Limit | Default |
|
| Limit | Default |
|
||||||
| ------------------------------ | -----------------: |
|
| ------------------------------- | -----------------: |
|
||||||
| Live parse debounce | 120 ms |
|
| Live parse debounce | 120 ms |
|
||||||
| Live execution debounce | 220 ms |
|
| Live execution debounce | 220 ms |
|
||||||
| Live execution timeout | 250 ms |
|
| Live execution timeout | 250 ms |
|
||||||
| Manual execution timeout | 2 s |
|
| Manual execution timeout | 2 s |
|
||||||
| Advanced maximum timeout | 10 s |
|
| Advanced maximum timeout | 10 s |
|
||||||
| Pattern hard limit | 64 Ki UTF-16 units |
|
| Pattern hard limit | 64 Ki UTF-16 units |
|
||||||
| Interactive subject hard limit | 16 MiB |
|
| Interactive subject hard limit | 16 MiB |
|
||||||
| Replacement template | 64 Ki UTF-16 units |
|
| Corpus documents | 256 |
|
||||||
| List template | 16 Ki UTF-16 units |
|
| Per corpus document | 16 MiB |
|
||||||
| Per-match replacement preview | 200 matches |
|
| Aggregate corpus input | 256 MiB |
|
||||||
| Replacement contributions | 2,000 |
|
| Independent corpus lines | 100,000 |
|
||||||
| Maximum matches | 10,000 |
|
| Aggregate corpus matches | 100,000 |
|
||||||
| Maximum capture groups | 1,000 |
|
| Capture summaries per document | 32 |
|
||||||
| Maximum capture rows | 100,000 |
|
| Samples per capture | 3 |
|
||||||
| Replacement preview | 64 MiB |
|
| Retained applied output | 64 MiB |
|
||||||
| Project JSON document | 32 MiB UTF-8 |
|
| Applied ZIP | 68 MiB |
|
||||||
| Standalone test-suite JSON | 32 MiB UTF-8 |
|
| Corpus batch wall time | 5 min |
|
||||||
| In-memory unit-test suite | 32 MiB UTF-8 |
|
| Replacement template | 64 Ki UTF-16 units |
|
||||||
| Tests per suite | 1,000 |
|
| List template | 16 Ki UTF-16 units |
|
||||||
| Retained message per test | 2 KiB UTF-8 |
|
| Per-match replacement preview | 200 matches |
|
||||||
| Unit-test suite wall time | 60 s |
|
| Replacement contributions | 2,000 |
|
||||||
|
| Maximum matches | 10,000 |
|
||||||
|
| Maximum capture groups | 1,000 |
|
||||||
|
| Maximum capture rows | 100,000 |
|
||||||
|
| Replacement preview | 64 MiB |
|
||||||
|
| Project JSON document | 32 MiB UTF-8 |
|
||||||
|
| Standalone test-suite JSON | 32 MiB UTF-8 |
|
||||||
|
| In-memory unit-test suite | 32 MiB UTF-8 |
|
||||||
|
| 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
|
A timeout terminates actual execution. It is reported as **timed out**, never as
|
||||||
“no match”. The next request creates a fresh worker.
|
“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
|
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,
|
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
|
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
|
32 MiB aggregate bound. Added generated tests retain their validated generator
|
||||||
release. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8
|
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
|
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
|
read through an `updatedAt` index cursor; loading it does not materialize the
|
||||||
complete project store.
|
complete project store.
|
||||||
@@ -154,6 +244,9 @@ npm run test:conformance
|
|||||||
npm run test:browser
|
npm run test:browser
|
||||||
npm run engines:build
|
npm run engines:build
|
||||||
npm run engines:verify
|
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 build
|
||||||
npm run toolbox:check
|
npm run toolbox:check
|
||||||
npm run 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 URL, manifest ID, version and SHA-256. Portal builds consume the
|
||||||
artifact; they do not build Regex Tools source.
|
artifact; they do not build Regex Tools source.
|
||||||
|
|
||||||
Version 0.1.0 has no WebAssembly. Its minimum CSP needs self-hosted scripts and
|
The PCRE2 worker loads self-hosted JavaScript and WebAssembly only. Deployment
|
||||||
workers, not `wasm-unsafe-eval`. See
|
must serve `.wasm` as `application/wasm` and allow WebAssembly compilation in
|
||||||
[`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md).
|
`script-src`; see [`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md).
|
||||||
|
|
||||||
## Flavour roadmap
|
## Flavour roadmap
|
||||||
|
|
||||||
The next flavour is official PCRE2 10.47 WebAssembly. A technical spike from the
|
PCRE2 10.47 is the second implemented flavour. Its application-owned ABI
|
||||||
signed official tag has already demonstrated Unicode matching, named captures,
|
provides bounded compilation, matching, native substitution and separate
|
||||||
substitution, automatic and explicit callouts, and match/depth/heap limits.
|
automatic-callout tracing, copied caller-owned records, deterministic
|
||||||
PCRE2 is not shipped until the application-owned bridge, byte/UTF-16 offsets,
|
allocation cleanup and explicit result completeness. The exact
|
||||||
trace caps, browser tests and source/licence material meet the acceptance gate.
|
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
|
Python, Go, Rust, .NET, Java and legacy PCRE follow only through their actual
|
||||||
named runtimes. No flavour is emulated through JavaScript and relabelled.
|
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,
|
No regex101 application source, branding, generated explanation prose,
|
||||||
reference text or visual design is copied. RegexLib and RGXP.RU fixtures are not
|
reference text or visual design is copied. RegexLib and RGXP.RU fixtures are not
|
||||||
redistributed because a suitable fixture licence was not established. No
|
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
|
## 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.
|
error node, not a fabricated multi-character span or partial provider AST.
|
||||||
- Native JavaScript compile errors do not consistently expose a machine-readable
|
- Native JavaScript compile errors do not consistently expose a machine-readable
|
||||||
source offset; the provider diagnostic supplies the range.
|
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
|
- Capture-table rendering is paged at 200 rows while aggregate collection is
|
||||||
bounded separately.
|
bounded separately.
|
||||||
- Explanation/extraction trees and editor decorations show bounded 2,000-node
|
- 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
|
- 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
|
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.
|
offer a lightweight `should-match` draft when one is valid and fits the suite.
|
||||||
- Benchmark, risk analysis, generated cases, minimization and corpus processing
|
- Corpus input accepts recognized UTF-8 text only. It does not auto-detect
|
||||||
are not in this release.
|
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
|
Architecture, security, performance, provenance and flavour details live in
|
||||||
[`docs/`](docs/).
|
[`docs/`](docs/).
|
||||||
|
|||||||
18
SOURCE.md
18
SOURCE.md
@@ -1,9 +1,10 @@
|
|||||||
# Source identity
|
# Source identity
|
||||||
|
|
||||||
- Project: Regex Tools
|
- 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>
|
- 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
|
- Licence: GPL-3.0-or-later
|
||||||
- Copyright: © 2026 Albrecht Degering
|
- 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,
|
repository. The release archive contains compiled static assets plus legal,
|
||||||
source-identity and attribution documents.
|
source-identity and attribution documents.
|
||||||
|
|
||||||
No PCRE2, Python, Go, Rust, .NET, Java or legacy-PCRE runtime is included in
|
The explicit offline PCRE2 build is documented in `docs/ENGINE_BUILDS.md`.
|
||||||
version 0.1.0.
|
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,27 +1,29 @@
|
|||||||
# Third-party notices
|
# Third-party notices
|
||||||
|
|
||||||
Regex Tools original source is GPL-3.0-or-later. The production browser bundle
|
Regex Tools original source is GPL-3.0-or-later. The production browser bundle
|
||||||
also contains the compatible components below. Exact dependency resolution is
|
contains the compatible runtime components below.
|
||||||
recorded in `package-lock.json`.
|
Exact dependency resolution is recorded in `package-lock.json`.
|
||||||
|
|
||||||
| Component | Version | Licence | Shipped | Role and source |
|
| 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-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 |
|
| `@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> |
|
| `@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> |
|
| 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> |
|
| `@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> |
|
| 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> |
|
||||||
| Vite | 8.1.5 | MIT | Generated helpers | Production build; <https://github.com/vitejs/vite> |
|
| Emscripten generated runtime | 6.0.4 | MIT | Generated WebAssembly glue | PCRE2 module loader/runtime; <https://github.com/emscripten-core/emscripten> |
|
||||||
| Rolldown | 1.1.5 | MIT | Generated helpers | Production bundling; <https://github.com/rolldown/rolldown> |
|
| 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> |
|
||||||
|
|
||||||
`@add-ideas/toolbox-testkit` 0.2.2 and the other test/build dependencies are
|
`@add-ideas/toolbox-testkit` 0.2.2 and the other test/build dependencies are
|
||||||
development-only and are not part of the static runtime bundle.
|
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
|
`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
|
not installed or shipped. PCRE2 source is not vendored; its generated
|
||||||
spike and is not present in the v0.1.0 artifact.
|
WebAssembly pack is shipped with exact metadata, checksums and licence.
|
||||||
|
|
||||||
Corresponding licence texts and copyright notices are in `LICENSES/`. The
|
Corresponding licence texts and copyright notices are in `LICENSES/`. The
|
||||||
release package additionally carries the exact Vite and Rolldown legal files
|
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
|
```text
|
||||||
React workbench
|
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
|
├─ 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
|
├─ Test SyntaxSupervisor + EngineSupervisor → isolated cancellable test runs
|
||||||
└─ project validator / serializer / IndexedDB persistence
|
└─ 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
|
rejects stale responses, terminates on timeout/crash/cancel, and lazily creates
|
||||||
a new generation. Timeout is a distinct error state.
|
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
|
Pattern and replacement syntax use independent supervisors. A pattern snapshot
|
||||||
is bound to its exact pattern, ordered flags and revision; execution cannot use
|
is bound to its exact pattern, ordered flags and revision; execution cannot use
|
||||||
acceptance or capture metadata from an earlier input. Replacement output is
|
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
|
output. They execute no replacement callback or user code and have separate
|
||||||
presentation and token-evaluation limits.
|
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
|
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
|
Unicode code-point offsets, including invalid-boundary rejection and
|
||||||
rejection and lone-surrogate detection.
|
lone-surrogate detection.
|
||||||
|
|
||||||
The release boundary is `dist/` plus checked-in legal/source documents. The
|
The release boundary is `dist/` plus checked-in legal/source documents. The
|
||||||
Portal consumes the resulting immutable ZIP and never imports React source.
|
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
|
# 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
|
## Rebuilding PCRE2
|
||||||
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.
|
|
||||||
|
|
||||||
## PCRE2 feasibility record
|
The reproducible build accepts only:
|
||||||
|
|
||||||
The next flavour was spiked outside the application repository against official
|
- PCRE2 tag `pcre2-10.47`, signed tag object
|
||||||
PCRE2 10.47:
|
`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`;
|
The source and compiler checkouts must already exist locally and be completely
|
||||||
- peeled commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429`;
|
clean. No command clones, downloads or updates them:
|
||||||
- licence `BSD-3-Clause WITH PCRE2-exception`;
|
|
||||||
- Emscripten 6.0.4;
|
|
||||||
- 8-bit library, Unicode enabled, JIT disabled.
|
|
||||||
|
|
||||||
Node and Chromium tests demonstrated version/config reporting, Unicode,
|
```sh
|
||||||
named groups, global substitution, automatic and explicit callouts, and
|
npm run engines:pcre2:build -- \
|
||||||
match/depth/heap errors. This was a technical spike only. No spike source,
|
--source-dir /absolute/path/to/pcre2 \
|
||||||
binary or PCRE2 source is included in v0.1.0.
|
--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,
|
Use `--force` only to replace the exact ignored `.engine-build/pcre2` output.
|
||||||
allocation cleanup, exact source build, UTF-8/UTF-16 mapping, trace/output caps,
|
The explicit install command first re-verifies that pack and then atomically
|
||||||
worker recovery, deterministic assets, licences and browser conformance.
|
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
|
# 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
|
PCRE2 10.47 has a separate actual automatic-callout vertical. ABI version 3
|
||||||
results but not the runtime's internal backtracking events. Regex Tools does
|
compiles the requested pattern with `PCRE2_AUTO_CALLOUT`, registers an
|
||||||
not label structural explanations, static hints or adjacent-result inference
|
application callback, and copies only:
|
||||||
as an actual engine trace.
|
|
||||||
|
|
||||||
The next PCRE2 slice will use actual automatic/explicit callout events from an
|
- callout number;
|
||||||
application-owned bridge. Event count, serialized bytes, match/depth/heap
|
- pattern byte position and next-item byte length;
|
||||||
limits and wall-clock termination must all be enforced. Classifications such
|
- subject byte position;
|
||||||
as “apparent backtrack” will remain visibly derived from adjacent events.
|
- 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
|
# Explanation model
|
||||||
|
|
||||||
The community provider converts regexpp nodes into a stable application AST
|
The ECMAScript community provider converts regexpp nodes into a stable
|
||||||
inside the syntax worker. Nodes carry deterministic IDs, UTF-16 ranges, raw
|
application AST inside the syntax worker. Nodes carry deterministic IDs,
|
||||||
source, capture metadata, quantifier bounds, assertion kind, support status,
|
UTF-16 ranges, raw source, capture metadata, quantifier bounds, assertion kind,
|
||||||
provider identity and provenance.
|
support status, provider identity and provenance.
|
||||||
|
|
||||||
Explanations are original deterministic text selected by node type. Nullable
|
Explanations are original deterministic text selected by node type. Nullable
|
||||||
and minimum/maximum consumed-length properties are derived recursively where
|
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
|
gets the provider's exact diagnostic plus an application-owned error node. No
|
||||||
partial regexpp AST is claimed.
|
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
|
The explanation tree is structural. It is never called an actual execution
|
||||||
trace. Selecting a node selects and scrolls its exact editor range; selecting
|
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 support
|
||||||
|
|
||||||
| Flavour | Syntax | Execution | Replacement | Captures | Trace | Native offsets |
|
| Flavour | Syntax | Execution | Replacement | Captures | Trace | Analysis | Generated cases | 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 |
|
| 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 | Unavailable in release | Technical spike only; not shipped | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
| 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 |
|
| PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable |
|
||||||
| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points |
|
| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points |
|
||||||
| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
|
||||||
| Rust `regex` | 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 | Planned UTF-16 |
|
| .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Unavailable | Unavailable | Planned UTF-16 |
|
||||||
| Java | Unavailable | Unavailable | 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,
|
ECMAScript tests cover its complete exposed flag matrix, internal indices,
|
||||||
named groups, unmatched/empty/repeated groups, lookbehind, backreferences,
|
zero-length iteration, captures, lookbehind, backreferences, Unicode properties
|
||||||
Unicode properties, astral input, replacement and result truncation.
|
and bounded replacement.
|
||||||
|
|
||||||
Actual browser identity is displayed because ECMAScript behavior can evolve
|
PCRE2 tests cover `g`, `i`, `m`, `s`, `x`, `U`, `J`, mandatory UTF/UCP,
|
||||||
with the runtime. Syntax acceptance and engine compilation are separate;
|
branch-reset groups, recursion, `\K`, duplicate names, native substitution,
|
||||||
neither silently rewrites the pattern.
|
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
|
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`
|
The product deliberately does not implement or reference an `@r101/parser`
|
||||||
profile. No commercial package alias, import, tarball, credential, licence key
|
profile. No commercial package alias, import, tarball, credential, licence key
|
||||||
or private CI path exists. Additional flavour syntax will be implemented
|
or private CI path exists. Additional flavour syntax will be implemented
|
||||||
incrementally as open-source providers behind the same application-owned
|
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
|
elapsed time and exact runtime identity but makes no universal throughput or
|
||||||
safety claim.
|
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
|
The production bundle separates syntax and execution workers. Interactive
|
||||||
syntax/extraction trees and editor decoration sets render at most 2,000 items
|
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
|
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
|
IndexedDB schema version 2 indexes `updatedAt`; latest-project loading opens one
|
||||||
descending cursor and validates only that record instead of calling `getAll()`.
|
descending cursor and validates only that record instead of calling `getAll()`.
|
||||||
|
|
||||||
Formal cold/warm benchmarks, p95 statistics and growth charts are deferred to
|
Corpus runs accept at most 256 documents, 16 MiB per document and 256 MiB of
|
||||||
the analysis milestone and will run in killable workers.
|
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
|
# 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:
|
Required delivery behavior:
|
||||||
|
|
||||||
@@ -9,11 +9,17 @@ Required delivery behavior:
|
|||||||
revalidation/no-cache resources.
|
revalidation/no-cache resources.
|
||||||
- Hashed Vite assets may use immutable long-term caching.
|
- Hashed Vite assets may use immutable long-term caching.
|
||||||
- CSP must allow `worker-src 'self' blob:`.
|
- 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
|
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/`.
|
relative target such as `apps/regex/`.
|
||||||
|
|
||||||
When PCRE2 ships later, the smallest additional change will be
|
The package, manifest, tag, artifact URL and checksum must all use the v0.2.0
|
||||||
`application/wasm` delivery and `script-src 'self' 'wasm-unsafe-eval'`.
|
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 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| `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. |
|
| RegexLib | Website inspected | Redistribution licence not established | No fixture copied. |
|
||||||
| RGXP.RU | 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`.
|
`tests/fixtures/README.md`.
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ on publication failure, replaces an existing exact version only with
|
|||||||
`--force`, and produces:
|
`--force`, and produces:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
release/regex-tools-0.1.0.zip
|
release/regex-tools-0.2.0.zip
|
||||||
release/regex-tools-0.1.0.zip.sha256
|
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.
|
- A terminated worker is never reused.
|
||||||
- Pattern, subject, replacement/list template, match, capture and output sizes
|
- Pattern, subject, replacement/list template, match, capture and output sizes
|
||||||
are bounded.
|
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 templates are strings; executable callbacks are unavailable.
|
||||||
- Replacement/list token decorations, rows, chips, diagnostics and list
|
- Replacement/list token decorations, rows, chips, diagnostics and list
|
||||||
evaluation work have separate presentation/operation caps; incomplete 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
|
- Unit-test assertion diagnostics never stringify complete large values and
|
||||||
retain at most 2 KiB UTF-8 per case. Oversized replacement output is not
|
retain at most 2 KiB UTF-8 per case. Oversized replacement output is not
|
||||||
copied into an exact current-result draft.
|
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`,
|
- No backend, uploads, telemetry, remote corpus URL, CDN, remote font, `eval`,
|
||||||
`Function`, arbitrary command line or unsafe HTML rendering exists.
|
`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
|
```text
|
||||||
default-src 'self';
|
default-src 'self';
|
||||||
script-src 'self';
|
script-src 'self' 'wasm-unsafe-eval';
|
||||||
worker-src 'self' blob:;
|
worker-src 'self' blob:;
|
||||||
connect-src 'self';
|
connect-src 'self';
|
||||||
img-src 'self' data:;
|
img-src 'self' data:;
|
||||||
@@ -44,8 +91,9 @@ base-uri 'none';
|
|||||||
form-action 'none';
|
form-action 'none';
|
||||||
```
|
```
|
||||||
|
|
||||||
The Toolbox shell currently requires inline styles. No `unsafe-eval` or
|
The Toolbox shell currently requires inline styles. Corpus ZIP generation and
|
||||||
`wasm-unsafe-eval` is required until an actual WebAssembly flavour ships.
|
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
|
Report vulnerabilities privately to the repository owner before public issue
|
||||||
details are posted.
|
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",
|
"coverage",
|
||||||
"test-results",
|
"test-results",
|
||||||
"playwright-report",
|
"playwright-report",
|
||||||
|
".engine-build",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "regex-tools",
|
"name": "regex-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "regex-tools",
|
"name": "regex-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.2",
|
"@add-ideas/toolbox-contract": "0.2.2",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@codemirror/view": "6.43.6",
|
"@codemirror/view": "6.43.6",
|
||||||
"@eslint-community/regexpp": "4.12.2",
|
"@eslint-community/regexpp": "4.12.2",
|
||||||
"@lezer/highlight": "1.2.3",
|
"@lezer/highlight": "1.2.3",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7"
|
||||||
},
|
},
|
||||||
@@ -35,7 +36,6 @@
|
|||||||
"eslint": "10.7.0",
|
"eslint": "10.7.0",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"eslint-plugin-react-refresh": "0.5.2",
|
"eslint-plugin-react-refresh": "0.5.2",
|
||||||
"fflate": "0.8.3",
|
|
||||||
"globals": "17.7.0",
|
"globals": "17.7.0",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
"prettier": "3.9.5",
|
"prettier": "3.9.5",
|
||||||
@@ -2442,7 +2442,6 @@
|
|||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "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.",
|
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
"test:conformance": "vitest run tests/conformance",
|
"test:conformance": "vitest run tests/conformance",
|
||||||
"test:browser": "playwright test",
|
"test:browser": "playwright test",
|
||||||
"engines:build": "node scripts/build-engines.mjs",
|
"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",
|
"engines:verify": "node scripts/verify-engine-assets.mjs",
|
||||||
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||||
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"@codemirror/view": "6.43.6",
|
"@codemirror/view": "6.43.6",
|
||||||
"@eslint-community/regexpp": "4.12.2",
|
"@eslint-community/regexpp": "4.12.2",
|
||||||
"@lezer/highlight": "1.2.3",
|
"@lezer/highlight": "1.2.3",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7"
|
||||||
},
|
},
|
||||||
@@ -68,7 +72,6 @@
|
|||||||
"eslint": "10.7.0",
|
"eslint": "10.7.0",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"eslint-plugin-react-refresh": "0.5.2",
|
"eslint-plugin-react-refresh": "0.5.2",
|
||||||
"fflate": "0.8.3",
|
|
||||||
"globals": "17.7.0",
|
"globals": "17.7.0",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
"prettier": "3.9.5",
|
"prettier": "3.9.5",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Engine assets
|
# Engine assets
|
||||||
|
|
||||||
Regex Tools 0.1.0 executes ECMAScript through the browser's native `RegExp`
|
Regex Tools executes ECMAScript through the browser's native `RegExp` engine.
|
||||||
engine. It therefore ships no external engine binary or WebAssembly pack.
|
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
|
The PCRE2 pack is built from pinned, documented open-source code and
|
||||||
revisions and placed in versioned subdirectories here.
|
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,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.regex-tools",
|
"id": "de.add-ideas.regex-tools",
|
||||||
"name": "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.",
|
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
import { readFile, readdir } from "node:fs/promises";
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
buildPcre2Pack,
|
||||||
|
parsePcre2BuildArguments,
|
||||||
|
} from "./pcre2-engine-pack.mjs";
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
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(
|
const packageJson = JSON.parse(
|
||||||
await readFile(path.join(root, "package.json"), "utf8"),
|
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();
|
const entries = (await readdir(engineDirectory)).sort();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
packageJson.version !== "0.1.0" ||
|
typeof packageJson.version !== "string" ||
|
||||||
entries.length !== 1 ||
|
entries.length !== 2 ||
|
||||||
entries[0] !== "README.md"
|
entries[0] !== "README.md" ||
|
||||||
|
entries[1] !== "pcre2"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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(
|
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 sourcePath = join(root, "src", "toolbox", "manifest.source.json");
|
||||||
const outputPath = join(root, "public", "toolbox-app.json");
|
const outputPath = join(root, "public", "toolbox-app.json");
|
||||||
const packagePath = join(root, "package.json");
|
const packagePath = join(root, "package.json");
|
||||||
|
const applicationVersionPath = join(root, "src", "version.ts");
|
||||||
const checkOnly = process.argv.includes("--check");
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||||
const packageJson = JSON.parse(await readFile(packagePath, "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(
|
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 zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
||||||
const gplVersion3TextSha256 =
|
const gplVersion3TextSha256 =
|
||||||
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
|
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
|
||||||
|
const expectedApplicationVersion = "0.2.0";
|
||||||
const requiredRootFiles = [
|
const requiredRootFiles = [
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"README.md",
|
"README.md",
|
||||||
@@ -332,13 +333,27 @@ async function collectReleaseEntries() {
|
|||||||
await readFile(path.join(root, "package.json"), "utf8"),
|
await readFile(path.join(root, "package.json"), "utf8"),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
packageJson.version !== "0.1.0" ||
|
packageJson.version !== expectedApplicationVersion ||
|
||||||
packageJson.license !== "GPL-3.0-or-later" ||
|
packageJson.license !== "GPL-3.0-or-later" ||
|
||||||
packageJson.repository?.url !==
|
packageJson.repository?.url !==
|
||||||
"git+https://git.add-ideas.de/zemion/regex-tools.git"
|
"git+https://git.add-ideas.de/zemion/regex-tools.git"
|
||||||
) {
|
) {
|
||||||
throw new Error("Package version, licence or source identity drifted.");
|
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");
|
const licence = await readFile(path.join(root, "LICENSE"), "utf8");
|
||||||
if (
|
if (
|
||||||
!licence.includes("GNU GENERAL PUBLIC LICENSE") ||
|
!licence.includes("GNU GENERAL PUBLIC LICENSE") ||
|
||||||
@@ -371,6 +386,14 @@ async function collectReleaseEntries() {
|
|||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"SOURCE.md",
|
"SOURCE.md",
|
||||||
"THIRD_PARTY_NOTICES.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/rolldown-1.1.5/LICENSE",
|
||||||
"LICENSES/build/vite-8.1.5/LICENSE.md",
|
"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"],
|
[".css", "text/css; charset=utf-8"],
|
||||||
[".html", "text/html; charset=utf-8"],
|
[".html", "text/html; charset=utf-8"],
|
||||||
[".js", "text/javascript; charset=utf-8"],
|
[".js", "text/javascript; charset=utf-8"],
|
||||||
|
[".mjs", "text/javascript; charset=utf-8"],
|
||||||
[".json", "application/json; charset=utf-8"],
|
[".json", "application/json; charset=utf-8"],
|
||||||
[".svg", "image/svg+xml"],
|
[".svg", "image/svg+xml"],
|
||||||
[".wasm", "application/wasm"],
|
[".wasm", "application/wasm"],
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { verifyPcre2Pack } from "./pcre2-engine-pack.mjs";
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
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 engineDirectory = path.join(root, "dist", "engines");
|
||||||
const details = await lstat(engineDirectory);
|
const details = await lstat(engineDirectory);
|
||||||
if (details.isSymbolicLink() || !details.isDirectory()) {
|
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,
|
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
entries.length !== 1 ||
|
entries.length !== 2 ||
|
||||||
!entries[0]?.isFile() ||
|
!entries[0]?.isFile() ||
|
||||||
entries[0].name !== "README.md"
|
entries[0].name !== "README.md" ||
|
||||||
|
!entries[1]?.isDirectory() ||
|
||||||
|
entries[1].name !== "pcre2"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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");
|
const notice = await readFile(path.join(engineDirectory, "README.md"), "utf8");
|
||||||
if (
|
if (!notice.includes("native `RegExp`") || !notice.includes("PCRE2 10.47")) {
|
||||||
!notice.includes("native `RegExp`") ||
|
|
||||||
!notice.includes("no external engine")
|
|
||||||
) {
|
|
||||||
throw new Error("The engine asset notice does not describe this release.");
|
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 { 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 { RegexExecutionResult } from "../regex/model/match";
|
||||||
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
||||||
import { CapabilityPanel } from "./CapabilityPanel";
|
import { CapabilityPanel } from "./CapabilityPanel";
|
||||||
@@ -78,11 +79,66 @@ describe("CapabilityPanel", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("Fixture engine")).toBeInTheDocument();
|
expect(screen.getByText("Fixture engine")).toBeInTheDocument();
|
||||||
expect(screen.getByText("123")).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("UTF-8 bytes")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("fixture")).toBeInTheDocument();
|
||||||
|
|
||||||
const replacement = screen.getByText("Replacement").closest("div");
|
const replacement = screen.getByText("Replacement").closest("div");
|
||||||
expect(replacement).toHaveTextContent("Unavailable");
|
expect(replacement).toHaveTextContent("Unavailable");
|
||||||
const compilation = screen.getByText("Compilation").closest("div");
|
const compilation = screen.getByText("Compilation").closest("div");
|
||||||
expect(compilation).toHaveTextContent("Available");
|
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 { RegexExecutionResult } from "../regex/model/match";
|
||||||
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
||||||
import type { RegexEngineCapabilities } from "../regex/model/flavour";
|
import type { RegexEngineCapabilities } from "../regex/model/flavour";
|
||||||
@@ -35,23 +35,27 @@ function offsetLabel(
|
|||||||
export function CapabilityPanel({
|
export function CapabilityPanel({
|
||||||
syntax,
|
syntax,
|
||||||
execution,
|
execution,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
readonly syntax?: RegexSyntaxResult;
|
readonly syntax?: RegexSyntaxResult;
|
||||||
readonly execution?: RegexExecutionResult;
|
readonly execution?: RegexExecutionResult;
|
||||||
|
readonly onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const capabilities = execution?.engine.capabilities;
|
const capabilities = execution?.engine.capabilities;
|
||||||
|
const activeFlavour =
|
||||||
|
execution?.engine.flavour ?? syntax?.root.support.flavour;
|
||||||
const rows = [
|
const rows = [
|
||||||
[
|
[
|
||||||
"Flavour",
|
"Flavour",
|
||||||
execution?.engine.flavour ??
|
execution?.engine.flavour ??
|
||||||
syntax?.root.support.flavour ??
|
syntax?.root.support.flavour ??
|
||||||
"ECMAScript (awaiting workers)",
|
"Awaiting flavour workers",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Syntax provider",
|
"Syntax provider",
|
||||||
syntax
|
syntax
|
||||||
? `${syntax.provider.id} ${syntax.provider.version}`
|
? `${syntax.provider.id} ${syntax.provider.version}`
|
||||||
: `regexpp ${REGEXPP_VERSION} (awaiting syntax worker)`,
|
: "Awaiting syntax worker result",
|
||||||
],
|
],
|
||||||
["Syntax profile", SYNTAX_PROFILE],
|
["Syntax profile", SYNTAX_PROFILE],
|
||||||
[
|
[
|
||||||
@@ -65,9 +69,20 @@ export function CapabilityPanel({
|
|||||||
execution?.engine.engineName ?? "Awaiting first engine result",
|
execution?.engine.engineName ?? "Awaiting first engine result",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Engine/runtime version",
|
"Engine version",
|
||||||
execution?.engine.engineVersion ?? "Shown after first execution",
|
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)],
|
["Native offsets", offsetLabel(execution?.engine.offsetUnit)],
|
||||||
["Compilation", capability(capabilities, "compilation")],
|
["Compilation", capability(capabilities, "compilation")],
|
||||||
["Matching", capability(capabilities, "matching")],
|
["Matching", capability(capabilities, "matching")],
|
||||||
@@ -90,6 +105,38 @@ export function CapabilityPanel({
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
["Benchmark", capability(capabilities, "benchmark")],
|
["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;
|
] as const;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -101,7 +148,20 @@ export function CapabilityPanel({
|
|||||||
<p className="eyebrow">Current provider and adapter metadata</p>
|
<p className="eyebrow">Current provider and adapter metadata</p>
|
||||||
<h2 id="capability-heading">Capabilities</h2>
|
<h2 id="capability-heading">Capabilities</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="provenance-badge">Community build</span>
|
<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>
|
</header>
|
||||||
<dl className="capability-grid">
|
<dl className="capability-grid">
|
||||||
{rows.map(([term, value]) => (
|
{rows.map(([term, value]) => (
|
||||||
@@ -112,10 +172,11 @@ export function CapabilityPanel({
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
<p className="capability-roadmap">
|
<p className="capability-roadmap">
|
||||||
Next flavour: official PCRE2 10.47 WebAssembly with actual callout
|
PCRE2 10.47 matching and substitution run in the bundled WebAssembly
|
||||||
traces. Python, Go, Rust, .NET and Java remain unavailable until their
|
worker. Its separate trace worker exposes bounded reported automatic
|
||||||
named runtimes pass the same worker, offset, conformance and licensing
|
callouts; movement classifications remain explicitly derived. Python,
|
||||||
gates.
|
Go, Rust, .NET and Java remain unavailable until their named runtimes
|
||||||
|
pass the same worker, offset, conformance and licensing gates.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</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 { useEffect, useRef } from "react";
|
||||||
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
|
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";
|
import { manifest } from "../toolbox/manifest";
|
||||||
|
|
||||||
const releaseSourceUrl = `https://git.add-ideas.de/zemion/regex-tools/src/tag/v${manifest.version}`;
|
const releaseSourceUrl = `https://git.add-ideas.de/zemion/regex-tools/src/tag/v${manifest.version}`;
|
||||||
@@ -43,10 +47,11 @@ export function HelpDialog({
|
|||||||
<section>
|
<section>
|
||||||
<h3>What runs where</h3>
|
<h3>What runs where</h3>
|
||||||
<p>
|
<p>
|
||||||
Syntax parsing and actual ECMAScript execution run in separate,
|
Syntax parsing and actual ECMAScript or PCRE2 execution run in
|
||||||
killable browser workers. Patterns, subjects and projects are never
|
separate, killable browser workers. PCRE2 is the bundled,
|
||||||
uploaded. There is no backend, account, telemetry, CDN dependency,
|
self-hosted 10.47 WebAssembly engine. Patterns, subjects and
|
||||||
or executable replacement function.
|
projects are never uploaded. There is no backend, account,
|
||||||
|
telemetry, CDN dependency, or executable replacement function.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -56,8 +61,47 @@ export function HelpDialog({
|
|||||||
syntax provider. The extraction tree contains actual browser-engine
|
syntax provider. The extraction tree contains actual browser-engine
|
||||||
matches and captures. Replacement mode adds a typed token tree and
|
matches and captures. Replacement mode adds a typed token tree and
|
||||||
bounded per-match contribution/range mapping over those actual
|
bounded per-match contribution/range mapping over those actual
|
||||||
matches. None is an internal V8, SpiderMonkey or JavaScriptCore
|
matches. These views are not internal engine traces. The separate
|
||||||
execution trace.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -105,6 +149,78 @@ export function HelpDialog({
|
|||||||
<dt>Unit-test suite wall time</dt>
|
<dt>Unit-test suite wall time</dt>
|
||||||
<dd>60 seconds</dd>
|
<dd>60 seconds</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
<p>
|
<p>
|
||||||
A timeout terminates the worker. It means “timed out”, never “no
|
A timeout terminates the worker. It means “timed out”, never “no
|
||||||
@@ -116,7 +232,15 @@ export function HelpDialog({
|
|||||||
<p>
|
<p>
|
||||||
Ad-hoc test text is excluded from local saves and exports by
|
Ad-hoc test text is excluded from local saves and exports by
|
||||||
default. Test definitions are deliberate project data. Import is
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<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>
|
<p>
|
||||||
<strong>{project.name}</strong> · schema {project.schemaVersion}
|
<strong>{project.name}</strong> · schema {project.schemaVersion}
|
||||||
</p>
|
</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">
|
<label className="check-control">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { QuickReference } from "./QuickReference";
|
|||||||
|
|
||||||
describe("QuickReference", () => {
|
describe("QuickReference", () => {
|
||||||
it("shows flavour, flags, example and authoritative source before insertion", async () => {
|
it("shows flavour, flags, example and authoritative source before insertion", async () => {
|
||||||
const onInsert = vi.fn();
|
const onUsePattern = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<QuickReference onInsert={onInsert} />);
|
render(<QuickReference onUsePattern={onUsePattern} />);
|
||||||
|
|
||||||
await user.type(
|
await user.type(
|
||||||
screen.getByLabelText("Filter constructs"),
|
screen.getByLabelText("Filter constructs"),
|
||||||
@@ -20,7 +20,37 @@ describe("QuickReference", () => {
|
|||||||
screen.getByRole("link", { name: "ECMAScript Atom grammar" }),
|
screen.getByRole("link", { name: "ECMAScript Atom grammar" }),
|
||||||
).toHaveAttribute("href", expect.stringContaining("tc39.es"));
|
).toHaveAttribute("href", expect.stringContaining("tc39.es"));
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Insert example" }));
|
await user.click(
|
||||||
expect(onInsert).toHaveBeenCalledWith("a.b");
|
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,30 +1,44 @@
|
|||||||
import { useMemo, useState } from "react";
|
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({
|
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 [query, setQuery] = useState("");
|
||||||
|
const reference =
|
||||||
|
flavour === "pcre2" ? PCRE2_REFERENCE : ECMASCRIPT_REFERENCE;
|
||||||
const entries = useMemo(() => {
|
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
|
return needle
|
||||||
? ECMASCRIPT_REFERENCE.filter((entry) =>
|
? reference.filter((entry) =>
|
||||||
[
|
normalizeSearchText(
|
||||||
entry.construct,
|
[
|
||||||
entry.syntax,
|
entry.construct,
|
||||||
entry.explanation,
|
entry.syntax,
|
||||||
entry.example,
|
entry.explanation,
|
||||||
entry.flags.join(" "),
|
entry.example,
|
||||||
entry.source,
|
entry.flags.join(" "),
|
||||||
]
|
entry.source,
|
||||||
.join(" ")
|
].join(" "),
|
||||||
.toLocaleLowerCase()
|
).includes(needle),
|
||||||
.includes(needle),
|
|
||||||
)
|
)
|
||||||
: ECMASCRIPT_REFERENCE;
|
: reference;
|
||||||
}, [query]);
|
}, [query, reference]);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="panel reference-panel"
|
className="panel reference-panel"
|
||||||
@@ -32,9 +46,27 @@ export function QuickReference({
|
|||||||
>
|
>
|
||||||
<header className="panel-heading">
|
<header className="panel-heading">
|
||||||
<div>
|
<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>
|
<h2 id="reference-heading">Quick reference</h2>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
<label className="stacked-control">
|
<label className="stacked-control">
|
||||||
<span>Filter constructs</span>
|
<span>Filter constructs</span>
|
||||||
@@ -44,51 +76,57 @@ export function QuickReference({
|
|||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<ul className="reference-list">
|
{entries.length > 0 ? (
|
||||||
{entries.map((entry) => (
|
<ul className="reference-list">
|
||||||
<li key={entry.id}>
|
{entries.map((entry) => (
|
||||||
<div>
|
<li key={entry.id}>
|
||||||
<strong>{entry.construct}</strong>
|
|
||||||
<code>{entry.syntax}</code>
|
|
||||||
</div>
|
|
||||||
<p>{entry.explanation}</p>
|
|
||||||
<dl className="reference-meta">
|
|
||||||
<div>
|
<div>
|
||||||
<dt>Flavour</dt>
|
<strong>{entry.construct}</strong>
|
||||||
<dd>{entry.flavours.join(", ")}</dd>
|
<code>{entry.syntax}</code>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p>{entry.explanation}</p>
|
||||||
<dt>Relevant flags</dt>
|
<dl className="reference-meta">
|
||||||
<dd>
|
<div>
|
||||||
{entry.flags.length > 0 ? entry.flags.join(", ") : "None"}
|
<dt>Flavour</dt>
|
||||||
</dd>
|
<dd>{entry.flavours.join(", ")}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Example</dt>
|
<dt>Relevant flags</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<code>{entry.example}</code>
|
{entry.flags.length > 0 ? entry.flags.join(", ") : "None"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Source</dt>
|
<dt>Example</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href={entry.sourceUrl} target="_blank" rel="noreferrer">
|
<code>{entry.example}</code>
|
||||||
{entry.source}
|
</dd>
|
||||||
</a>
|
</div>
|
||||||
</dd>
|
<div>
|
||||||
</div>
|
<dt>Source</dt>
|
||||||
</dl>
|
<dd>
|
||||||
{entry.caveat ? <small>{entry.caveat}</small> : null}
|
<a href={entry.sourceUrl} target="_blank" rel="noreferrer">
|
||||||
<button
|
{entry.source}
|
||||||
type="button"
|
</a>
|
||||||
className="secondary-button"
|
</dd>
|
||||||
onClick={() => onInsert(entry.example)}
|
</div>
|
||||||
>
|
</dl>
|
||||||
Insert example
|
{entry.caveat ? <small>{entry.caveat}</small> : null}
|
||||||
</button>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
))}
|
className="secondary-button"
|
||||||
</ul>
|
onClick={() => onUsePattern(entry.example)}
|
||||||
|
>
|
||||||
|
Use example as pattern
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="empty-state" role="status">
|
||||||
|
No reference constructs match “{query.trim()}”.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
ReplacementContributionPreview,
|
ReplacementContributionPreview,
|
||||||
ReplacementPreview,
|
ReplacementPreview,
|
||||||
} from "../regex/replacement/replacement-preview";
|
} from "../regex/replacement/replacement-preview";
|
||||||
|
import type { RegexFlavourId } from "../regex/model/flavour";
|
||||||
import {
|
import {
|
||||||
DEFAULT_REGEX_LIMITS,
|
DEFAULT_REGEX_LIMITS,
|
||||||
TEMPLATE_PRESENTATION_LIMITS,
|
TEMPLATE_PRESENTATION_LIMITS,
|
||||||
@@ -50,6 +51,7 @@ function ReplacementTokenRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReplacementPanel({
|
export function ReplacementPanel({
|
||||||
|
flavour,
|
||||||
replacement,
|
replacement,
|
||||||
onReplacementChange,
|
onReplacementChange,
|
||||||
syntax,
|
syntax,
|
||||||
@@ -61,6 +63,7 @@ export function ReplacementPanel({
|
|||||||
onSelectToken,
|
onSelectToken,
|
||||||
onSelectContribution,
|
onSelectContribution,
|
||||||
}: {
|
}: {
|
||||||
|
readonly flavour?: RegexFlavourId;
|
||||||
readonly replacement: string;
|
readonly replacement: string;
|
||||||
readonly onReplacementChange: (value: string) => void;
|
readonly onReplacementChange: (value: string) => void;
|
||||||
readonly syntax?: ReplacementSyntaxResult;
|
readonly syntax?: ReplacementSyntaxResult;
|
||||||
@@ -74,6 +77,9 @@ export function ReplacementPanel({
|
|||||||
contribution: ReplacementContributionPreview,
|
contribution: ReplacementContributionPreview,
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const activeFlavour =
|
||||||
|
flavour ?? result?.execution.engine.flavour ?? "ecmascript";
|
||||||
|
const engineLabel = activeFlavour === "pcre2" ? "PCRE2" : "ECMAScript";
|
||||||
const retainedTokens = syntax?.tokens ?? [];
|
const retainedTokens = syntax?.tokens ?? [];
|
||||||
const editorTokens = retainedTokens.slice(
|
const editorTokens = retainedTokens.slice(
|
||||||
0,
|
0,
|
||||||
@@ -94,7 +100,7 @@ export function ReplacementPanel({
|
|||||||
<section className="panel editor-panel">
|
<section className="panel editor-panel">
|
||||||
<header className="panel-heading">
|
<header className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">ECMAScript template syntax</p>
|
<p className="eyebrow">{engineLabel} template syntax</p>
|
||||||
<h2>Replacement</h2>
|
<h2>Replacement</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="provenance-badge">Templates only · no code</span>
|
<span className="provenance-badge">Templates only · no code</span>
|
||||||
@@ -230,7 +236,7 @@ export function ReplacementPanel({
|
|||||||
<header className="panel-heading">
|
<header className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">
|
<p className="eyebrow">
|
||||||
Native matches · bounded ECMAScript substitution
|
Native matches · bounded {engineLabel} substitution
|
||||||
</p>
|
</p>
|
||||||
<h2>Replacement output</h2>
|
<h2>Replacement output</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function renderPanel(
|
|||||||
const props: UnitTestPanelProps = {
|
const props: UnitTestPanelProps = {
|
||||||
tests: [],
|
tests: [],
|
||||||
results: new Map<string, RegexTestResult>(),
|
results: new Map<string, RegexTestResult>(),
|
||||||
|
currentFlavour: "ecmascript",
|
||||||
currentPattern: "current-pattern",
|
currentPattern: "current-pattern",
|
||||||
currentFlags: ["u"],
|
currentFlags: ["u"],
|
||||||
currentScanAll: false,
|
currentScanAll: false,
|
||||||
@@ -489,6 +490,41 @@ describe("UnitTestPanel", () => {
|
|||||||
expect(screen.queryByText("Passing case")).not.toBeInTheDocument();
|
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 () => {
|
it("adds a bounded snapshot of the latest current result", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const currentResultDraft: RegexTestDraft = {
|
const currentResultDraft: RegexTestDraft = {
|
||||||
|
|||||||
@@ -22,21 +22,17 @@ import {
|
|||||||
MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
|
MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
|
||||||
serializeRegexTestSuite,
|
serializeRegexTestSuite,
|
||||||
} from "../regex/tests/test-suite.serialization";
|
} 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_COUNT = 1_000_000;
|
||||||
const MAXIMUM_MATCH_INDEX = 1_000_000;
|
const MAXIMUM_MATCH_INDEX = 1_000_000;
|
||||||
const MAXIMUM_CAPTURE_NUMBER = 1_000;
|
const MAXIMUM_CAPTURE_NUMBER = 1_000;
|
||||||
const MAXIMUM_TIMEOUT_MS = DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs;
|
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 CaptureSelector = "number" | "name";
|
||||||
type CaptureStatus = "participated" | "did-not-participate" | "matched-empty";
|
type CaptureStatus = "participated" | "did-not-participate" | "matched-empty";
|
||||||
@@ -52,11 +48,13 @@ export interface RegexTestDraft {
|
|||||||
readonly resourceLimits?: Readonly<
|
readonly resourceLimits?: Readonly<
|
||||||
Pick<RegexResourceLimits, "manualExecutionTimeoutMs">
|
Pick<RegexResourceLimits, "manualExecutionTimeoutMs">
|
||||||
>;
|
>;
|
||||||
|
readonly generation?: RegexTestCase["generation"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnitTestPanelProps {
|
export interface UnitTestPanelProps {
|
||||||
readonly tests: readonly RegexTestCase[];
|
readonly tests: readonly RegexTestCase[];
|
||||||
readonly results: ReadonlyMap<string, RegexTestResult>;
|
readonly results: ReadonlyMap<string, RegexTestResult>;
|
||||||
|
readonly currentFlavour: RegexFlavourId;
|
||||||
readonly currentPattern: string;
|
readonly currentPattern: string;
|
||||||
readonly currentFlags: readonly string[];
|
readonly currentFlags: readonly string[];
|
||||||
readonly currentScanAll: boolean;
|
readonly currentScanAll: boolean;
|
||||||
@@ -104,7 +102,10 @@ function boundedText(value: string, label: string, maximum: number): string {
|
|||||||
return value;
|
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.name, "Test name", MAXIMUM_REGEX_TEST_NAME_UTF16);
|
||||||
boundedText(draft.pattern, "Pattern", MAXIMUM_REGEX_TEST_PATTERN_UTF16);
|
boundedText(draft.pattern, "Pattern", MAXIMUM_REGEX_TEST_PATTERN_UTF16);
|
||||||
boundedText(draft.subject, "Subject", MAXIMUM_REGEX_TEST_SUBJECT_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.`,
|
`Subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (new Set(draft.flags).size !== draft.flags.length) {
|
parseRegexFlags(draft.flags, flavour, "Flags");
|
||||||
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.");
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
draft.replacement !== undefined &&
|
draft.replacement !== undefined &&
|
||||||
draft.replacement.length >
|
draft.replacement.length >
|
||||||
@@ -211,6 +200,7 @@ function downloadJson(serialized: string): void {
|
|||||||
export function UnitTestPanel({
|
export function UnitTestPanel({
|
||||||
tests,
|
tests,
|
||||||
results,
|
results,
|
||||||
|
currentFlavour,
|
||||||
currentPattern,
|
currentPattern,
|
||||||
currentFlags,
|
currentFlags,
|
||||||
currentScanAll,
|
currentScanAll,
|
||||||
@@ -228,6 +218,7 @@ export function UnitTestPanel({
|
|||||||
onCancel,
|
onCancel,
|
||||||
running,
|
running,
|
||||||
}: UnitTestPanelProps) {
|
}: UnitTestPanelProps) {
|
||||||
|
const flavourDefinition = AVAILABLE_REGEX_FLAVOURS.require(currentFlavour);
|
||||||
const helpId = useId();
|
const helpId = useId();
|
||||||
const errorId = useId();
|
const errorId = useId();
|
||||||
const formId = useId();
|
const formId = useId();
|
||||||
@@ -357,24 +348,27 @@ export function UnitTestPanel({
|
|||||||
1,
|
1,
|
||||||
MAXIMUM_TIMEOUT_MS,
|
MAXIMUM_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
return validateDraft({
|
return validateDraft(
|
||||||
name: name.trim() || "Untitled test",
|
{
|
||||||
pattern,
|
name: name.trim() || "Untitled test",
|
||||||
flags,
|
pattern,
|
||||||
scanAll,
|
flags,
|
||||||
subject,
|
scanAll,
|
||||||
expectation: createExpectation(),
|
subject,
|
||||||
...(expectationKind === "replacement" && includeReplacement
|
expectation: createExpectation(),
|
||||||
? { replacement }
|
...(expectationKind === "replacement" && includeReplacement
|
||||||
: {}),
|
? { replacement }
|
||||||
...(timeout === undefined
|
: {}),
|
||||||
? {}
|
...(timeout === undefined
|
||||||
: {
|
? {}
|
||||||
resourceLimits: {
|
: {
|
||||||
manualExecutionTimeoutMs: timeout,
|
resourceLimits: {
|
||||||
},
|
manualExecutionTimeoutMs: timeout,
|
||||||
}),
|
},
|
||||||
});
|
}),
|
||||||
|
},
|
||||||
|
flavourDefinition,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitTest(): void {
|
function submitTest(): void {
|
||||||
@@ -499,7 +493,7 @@ export function UnitTestPanel({
|
|||||||
function addCurrentResult(): void {
|
function addCurrentResult(): void {
|
||||||
if (running || maximumReached || !currentResultDraft) return;
|
if (running || maximumReached || !currentResultDraft) return;
|
||||||
try {
|
try {
|
||||||
onAdd(validateDraft(currentResultDraft));
|
onAdd(validateDraft(currentResultDraft, flavourDefinition));
|
||||||
setFormError(undefined);
|
setFormError(undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFormError(error instanceof Error ? error.message : String(error));
|
setFormError(error instanceof Error ? error.message : String(error));
|
||||||
@@ -585,27 +579,36 @@ export function UnitTestPanel({
|
|||||||
</label>
|
</label>
|
||||||
<fieldset className="test-flag-selector">
|
<fieldset className="test-flag-selector">
|
||||||
<legend>Flags</legend>
|
<legend>Flags</legend>
|
||||||
{FLAG_OPTIONS.map(([flag, label]) => (
|
{flavourDefinition.flags.map((flag) => (
|
||||||
<label key={flag} title={label}>
|
<label key={flag.value} title={flag.label}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${label} (${flag})`}
|
aria-label={`${flag.label} (${flag.value})`}
|
||||||
checked={flags.includes(flag)}
|
checked={flags.includes(flag.value)}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setFlags((current) => {
|
setFlags((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (event.target.checked) {
|
if (event.target.checked) {
|
||||||
next.add(flag);
|
next.add(flag.value);
|
||||||
if (flag === "u") next.delete("v");
|
for (const group of flavourDefinition.mutuallyExclusiveFlags ??
|
||||||
if (flag === "v") next.delete("u");
|
[]) {
|
||||||
|
if (!group.includes(flag.value)) continue;
|
||||||
|
for (const incompatible of group) {
|
||||||
|
if (incompatible !== flag.value) {
|
||||||
|
next.delete(incompatible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
next.delete(flag);
|
next.delete(flag.value);
|
||||||
}
|
}
|
||||||
return [...next];
|
return flavourDefinition.flags
|
||||||
|
.map((definition) => definition.value)
|
||||||
|
.filter((value) => next.has(value));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{flag}</span>
|
<span>{flag.value}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1041,6 +1044,9 @@ export function UnitTestPanel({
|
|||||||
/{test.pattern}/{test.flags.join("")} ·{" "}
|
/{test.pattern}/{test.flags.join("")} ·{" "}
|
||||||
{expectationSummary(test.expectation)} ·{" "}
|
{expectationSummary(test.expectation)} ·{" "}
|
||||||
{test.subject.length.toLocaleString()} UTF-16 units
|
{test.subject.length.toLocaleString()} UTF-16 units
|
||||||
|
{test.generation
|
||||||
|
? ` · generated · seed ${test.generation.seed}`
|
||||||
|
: ""}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -240,6 +240,16 @@ describe("Workbench syntax coordination", () => {
|
|||||||
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
|
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
|
||||||
|
|
||||||
await advanceParseDebounce();
|
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, [
|
await resolvePattern(0, [
|
||||||
{
|
{
|
||||||
number: 1,
|
number: 1,
|
||||||
@@ -308,6 +318,9 @@ describe("Workbench syntax coordination", () => {
|
|||||||
});
|
});
|
||||||
expect(engineHarness.requests).toHaveLength(1);
|
expect(engineHarness.requests).toHaveLength(1);
|
||||||
expect(engineHarness.requests[0]).toMatchObject({
|
expect(engineHarness.requests[0]).toMatchObject({
|
||||||
|
flavour: "ecmascript",
|
||||||
|
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
||||||
|
options: {},
|
||||||
pattern: "(?<new>b)",
|
pattern: "(?<new>b)",
|
||||||
captureMetadata: [{ number: 1, name: "new" }],
|
captureMetadata: [{ number: 1, name: "new" }],
|
||||||
});
|
});
|
||||||
@@ -380,4 +393,47 @@ describe("Workbench syntax coordination", () => {
|
|||||||
)?.cancelCalls,
|
)?.cancelCalls,
|
||||||
).toBeGreaterThan(0);
|
).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 { SyntaxSupervisor } from "../regex/execution/SyntaxSupervisor";
|
||||||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||||||
import type { RegexDiagnostic } from "../regex/model/diagnostics";
|
import type { RegexDiagnostic } from "../regex/model/diagnostics";
|
||||||
|
import type {
|
||||||
|
RegexEngineOptions,
|
||||||
|
RegexFlavourId,
|
||||||
|
} from "../regex/model/flavour";
|
||||||
import type {
|
import type {
|
||||||
ExtractionNode,
|
ExtractionNode,
|
||||||
RegexExecutionRequest,
|
RegexExecutionRequest,
|
||||||
@@ -67,6 +71,20 @@ import { UnitTestPanel, type RegexTestDraft } from "./UnitTestPanel";
|
|||||||
import { QuickReference } from "./QuickReference";
|
import { QuickReference } from "./QuickReference";
|
||||||
import { CapabilityPanel } from "./CapabilityPanel";
|
import { CapabilityPanel } from "./CapabilityPanel";
|
||||||
import { ProjectPanel } from "./ProjectPanel";
|
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 =
|
const INITIAL_PATTERN =
|
||||||
"(?<date>\\d{4}-\\d{2}-\\d{2})\\s+(?<user>[\\p{Letter}._-]+)";
|
"(?<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_PATTERN_EDITOR_MARKS = 2_000;
|
||||||
const MAXIMUM_SUBJECT_EDITOR_MARKS = 2_000;
|
const MAXIMUM_SUBJECT_EDITOR_MARKS = 2_000;
|
||||||
const MAXIMUM_TEST_SUITE_WALL_TIME_MS = 60_000;
|
const MAXIMUM_TEST_SUITE_WALL_TIME_MS = 60_000;
|
||||||
const FLAG_OPTIONS = [
|
const INITIAL_FLAVOUR = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
|
||||||
["g", "Global"],
|
|
||||||
["i", "Ignore case"],
|
|
||||||
["m", "Multiline"],
|
|
||||||
["s", "Dot all"],
|
|
||||||
["u", "Unicode"],
|
|
||||||
["v", "Unicode sets"],
|
|
||||||
["y", "Sticky"],
|
|
||||||
["d", "Expose indices"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type RunState =
|
type RunState =
|
||||||
| { readonly status: "idle"; readonly message: string }
|
| { readonly status: "idle"; readonly message: string }
|
||||||
@@ -314,16 +323,23 @@ function testCaseFromDraft(
|
|||||||
id: string,
|
id: string,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
draft: RegexTestDraft,
|
draft: RegexTestDraft,
|
||||||
|
configuration: {
|
||||||
|
readonly flavour: RegexFlavourId;
|
||||||
|
readonly flavourVersion?: string;
|
||||||
|
readonly options: RegexEngineOptions;
|
||||||
|
},
|
||||||
): RegexTestCase {
|
): RegexTestCase {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: draft.name,
|
name: draft.name,
|
||||||
enabled,
|
enabled,
|
||||||
flavour: "ecmascript",
|
flavour: configuration.flavour,
|
||||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
...(configuration.flavourVersion === undefined
|
||||||
|
? {}
|
||||||
|
: { flavourVersion: configuration.flavourVersion }),
|
||||||
pattern: draft.pattern,
|
pattern: draft.pattern,
|
||||||
flags: draft.flags,
|
flags: draft.flags,
|
||||||
options: {},
|
options: configuration.options,
|
||||||
scanAll: draft.scanAll,
|
scanAll: draft.scanAll,
|
||||||
subject: draft.subject,
|
subject: draft.subject,
|
||||||
...(draft.replacement === undefined
|
...(draft.replacement === undefined
|
||||||
@@ -333,6 +349,7 @@ function testCaseFromDraft(
|
|||||||
...(draft.resourceLimits === undefined
|
...(draft.resourceLimits === undefined
|
||||||
? {}
|
? {}
|
||||||
: { resourceLimits: draft.resourceLimits }),
|
: { resourceLimits: draft.resourceLimits }),
|
||||||
|
...(draft.generation === undefined ? {} : { generation: draft.generation }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,8 +378,17 @@ function failedTestResult(testId: string, message: string): RegexTestResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Workbench() {
|
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 [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 [subject, setSubject] = useState(INITIAL_SUBJECT);
|
||||||
const [replacement, setReplacement] = useState(INITIAL_REPLACEMENT);
|
const [replacement, setReplacement] = useState(INITIAL_REPLACEMENT);
|
||||||
const [listTemplate, setListTemplate] = useState("${user} — ${date}");
|
const [listTemplate, setListTemplate] = useState("${user} — ${date}");
|
||||||
@@ -406,8 +432,15 @@ export function Workbench() {
|
|||||||
const [testsRunning, setTestsRunning] = useState(false);
|
const [testsRunning, setTestsRunning] = useState(false);
|
||||||
const [showReference, setShowReference] = useState(false);
|
const [showReference, setShowReference] = useState(false);
|
||||||
const [showCapabilities, setShowCapabilities] = 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 [showProject, setShowProject] = useState(false);
|
||||||
const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
|
const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
|
||||||
|
const [corpusSession, setCorpusSession] = useState(0);
|
||||||
const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 });
|
const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 });
|
||||||
const [projectIdentity, setProjectIdentity] = useState(() => ({
|
const [projectIdentity, setProjectIdentity] = useState(() => ({
|
||||||
id: projectId(),
|
id: projectId(),
|
||||||
@@ -423,6 +456,11 @@ export function Workbench() {
|
|||||||
const executionRevision = useRef(0);
|
const executionRevision = useRef(0);
|
||||||
const testRunRevision = useRef(0);
|
const testRunRevision = useRef(0);
|
||||||
const liveExecutionTimer = useRef<number | undefined>(undefined);
|
const liveExecutionTimer = useRef<number | undefined>(undefined);
|
||||||
|
const flavourDefinition = AVAILABLE_REGEX_FLAVOURS.require(flavour);
|
||||||
|
const activeTestConfiguration = useMemo(
|
||||||
|
() => ({ flavour, flavourVersion, options }),
|
||||||
|
[flavour, flavourVersion, options],
|
||||||
|
);
|
||||||
|
|
||||||
const patternSyntaxEngine = () => {
|
const patternSyntaxEngine = () => {
|
||||||
patternSyntaxSupervisor.current ??= new SyntaxSupervisor(
|
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
|
? syntaxSnapshot.result
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -545,19 +598,12 @@ export function Workbench() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void patternSyntaxEngine()
|
void patternSyntaxEngine()
|
||||||
.parsePattern({
|
.parsePattern(syntaxRequest)
|
||||||
flavour: "ecmascript",
|
|
||||||
flavourVersion: "2025",
|
|
||||||
pattern,
|
|
||||||
flags,
|
|
||||||
options: {},
|
|
||||||
})
|
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (syntaxRevision.current !== currentRevision) return;
|
if (syntaxRevision.current !== currentRevision) return;
|
||||||
setSyntaxSnapshot({
|
setSyntaxSnapshot({
|
||||||
revision: currentRevision,
|
revision: currentRevision,
|
||||||
pattern,
|
request: syntaxRequest,
|
||||||
flags,
|
|
||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
setSelectedSyntax((selected) =>
|
setSelectedSyntax((selected) =>
|
||||||
@@ -598,14 +644,14 @@ export function Workbench() {
|
|||||||
});
|
});
|
||||||
}, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
|
}, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [clearExecutionResults, flags, pattern]);
|
}, [clearExecutionResults, pattern, syntaxRequest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!syntax) return;
|
if (!syntax) return;
|
||||||
const currentRevision = ++replacementSyntaxRevision.current;
|
const currentRevision = ++replacementSyntaxRevision.current;
|
||||||
void replacementSyntaxEngine()
|
void replacementSyntaxEngine()
|
||||||
.parseReplacement({
|
.parseReplacement({
|
||||||
flavour: "ecmascript",
|
flavour,
|
||||||
replacement,
|
replacement,
|
||||||
captureMetadata: syntax.captures,
|
captureMetadata: syntax.captures,
|
||||||
})
|
})
|
||||||
@@ -622,22 +668,33 @@ export function Workbench() {
|
|||||||
console.warn("Replacement parsing failed", error);
|
console.warn("Replacement parsing failed", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [replacement, syntax]);
|
}, [flavour, replacement, syntax]);
|
||||||
|
|
||||||
const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]);
|
const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]);
|
||||||
|
|
||||||
const makeRequest = useCallback(
|
const makeRequest = useCallback(
|
||||||
(): RegexExecutionRequest => ({
|
(): RegexExecutionRequest => ({
|
||||||
flavour: "ecmascript",
|
flavour,
|
||||||
|
flavourVersion,
|
||||||
pattern,
|
pattern,
|
||||||
flags,
|
flags,
|
||||||
|
options,
|
||||||
subject,
|
subject,
|
||||||
captureMetadata: syntax?.captures ?? [],
|
captureMetadata: syntax?.captures ?? [],
|
||||||
scanAll,
|
scanAll,
|
||||||
maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches,
|
maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches,
|
||||||
maximumCaptureRows: DEFAULT_REGEX_LIMITS.maximumCaptureRows,
|
maximumCaptureRows: DEFAULT_REGEX_LIMITS.maximumCaptureRows,
|
||||||
}),
|
}),
|
||||||
[flags, pattern, scanAll, subject, syntax?.captures],
|
[
|
||||||
|
flags,
|
||||||
|
flavour,
|
||||||
|
flavourVersion,
|
||||||
|
options,
|
||||||
|
pattern,
|
||||||
|
scanAll,
|
||||||
|
subject,
|
||||||
|
syntax?.captures,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
@@ -653,8 +710,7 @@ export function Workbench() {
|
|||||||
!isPatternSyntaxCurrent(
|
!isPatternSyntaxCurrent(
|
||||||
syntaxSnapshot,
|
syntaxSnapshot,
|
||||||
syntaxRevision.current,
|
syntaxRevision.current,
|
||||||
pattern,
|
syntaxRequest,
|
||||||
flags,
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setRunState({
|
setRunState({
|
||||||
@@ -744,6 +800,7 @@ export function Workbench() {
|
|||||||
"current-result-size-check",
|
"current-result-size-check",
|
||||||
true,
|
true,
|
||||||
candidate,
|
candidate,
|
||||||
|
activeTestConfiguration,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
return true;
|
return true;
|
||||||
@@ -827,6 +884,7 @@ export function Workbench() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
activeTestConfiguration,
|
||||||
clearExecutionResults,
|
clearExecutionResults,
|
||||||
makeRequest,
|
makeRequest,
|
||||||
mode,
|
mode,
|
||||||
@@ -838,6 +896,7 @@ export function Workbench() {
|
|||||||
syntax?.accepted,
|
syntax?.accepted,
|
||||||
syntax?.captures.length,
|
syntax?.captures.length,
|
||||||
syntaxSnapshot,
|
syntaxSnapshot,
|
||||||
|
syntaxRequest,
|
||||||
flags,
|
flags,
|
||||||
pattern,
|
pattern,
|
||||||
tests,
|
tests,
|
||||||
@@ -855,6 +914,7 @@ export function Workbench() {
|
|||||||
!liveWithinLimits ||
|
!liveWithinLimits ||
|
||||||
!syntax?.accepted ||
|
!syntax?.accepted ||
|
||||||
mode === "tests" ||
|
mode === "tests" ||
|
||||||
|
mode === "corpus" ||
|
||||||
(mode === "replace" && replacementSyntax?.accepted !== true)
|
(mode === "replace" && replacementSyntax?.accepted !== true)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -935,11 +995,11 @@ export function Workbench() {
|
|||||||
name: "Regex workbench",
|
name: "Regex workbench",
|
||||||
createdAt: projectIdentity.createdAt,
|
createdAt: projectIdentity.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
flavour: "ecmascript",
|
flavour,
|
||||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
flavourVersion,
|
||||||
pattern,
|
pattern,
|
||||||
flags,
|
flags,
|
||||||
options: {},
|
options,
|
||||||
replacement,
|
replacement,
|
||||||
mode,
|
mode,
|
||||||
testText: { included: true, value: subject },
|
testText: { included: true, value: subject },
|
||||||
@@ -1171,6 +1231,9 @@ export function Workbench() {
|
|||||||
}
|
}
|
||||||
const parsed = await testSyntaxEngine().parsePattern({
|
const parsed = await testSyntaxEngine().parsePattern({
|
||||||
flavour: test.flavour,
|
flavour: test.flavour,
|
||||||
|
...(test.flavourVersion === undefined
|
||||||
|
? {}
|
||||||
|
: { flavourVersion: test.flavourVersion }),
|
||||||
pattern: test.pattern,
|
pattern: test.pattern,
|
||||||
flags: test.flags,
|
flags: test.flags,
|
||||||
options: test.options,
|
options: test.options,
|
||||||
@@ -1250,14 +1313,55 @@ export function Workbench() {
|
|||||||
`A suite is limited to ${MAXIMUM_REGEX_TESTS.toLocaleString()} tests.`,
|
`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);
|
assertRegexTestSuiteWithinLimit(next);
|
||||||
setTests(next);
|
setTests(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTest = (id: string, draft: RegexTestDraft) => {
|
const updateTest = (id: string, draft: RegexTestDraft) => {
|
||||||
const next = tests.map((test) =>
|
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);
|
assertRegexTestSuiteWithinLimit(next);
|
||||||
setTests(next);
|
setTests(next);
|
||||||
@@ -1308,10 +1412,20 @@ export function Workbench() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const importProject = (project: RegexProjectV1) => {
|
const importProject = (project: RegexProjectV1) => {
|
||||||
|
const importedFlavour = AVAILABLE_REGEX_FLAVOURS.require(project.flavour);
|
||||||
invalidatePatternSyntax();
|
invalidatePatternSyntax();
|
||||||
executionRevision.current += 1;
|
executionRevision.current += 1;
|
||||||
engineSupervisor.current?.cancel();
|
engineSupervisor.current?.cancel();
|
||||||
cancelTestRun();
|
cancelTestRun();
|
||||||
|
setFlavour(importedFlavour.id);
|
||||||
|
setFlavourVersion(
|
||||||
|
resolveRegexFlavourVersion(
|
||||||
|
project.flavourVersion,
|
||||||
|
importedFlavour,
|
||||||
|
"Project flavour version",
|
||||||
|
).definition.value,
|
||||||
|
);
|
||||||
|
setOptions(project.options);
|
||||||
setPattern(project.pattern);
|
setPattern(project.pattern);
|
||||||
setProjectIdentity({ id: project.id, createdAt: project.createdAt });
|
setProjectIdentity({ id: project.id, createdAt: project.createdAt });
|
||||||
setFlags(project.flags);
|
setFlags(project.flags);
|
||||||
@@ -1322,6 +1436,7 @@ export function Workbench() {
|
|||||||
setListTemplate(project.listTemplate?.source ?? "$0");
|
setListTemplate(project.listTemplate?.source ?? "$0");
|
||||||
setMode(project.mode);
|
setMode(project.mode);
|
||||||
setTests(project.tests);
|
setTests(project.tests);
|
||||||
|
setCorpusSession((current) => current + 1);
|
||||||
setCurrentResultDraft(undefined);
|
setCurrentResultDraft(undefined);
|
||||||
setTestResults(new Map());
|
setTestResults(new Map());
|
||||||
setLive(false);
|
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 (
|
return (
|
||||||
<main className="regex-application">
|
<main className="regex-application">
|
||||||
<section className="command-bar" aria-label="Regex execution controls">
|
<section className="command-bar" aria-label="Regex execution controls">
|
||||||
<label>
|
<label>
|
||||||
<span>Flavour</span>
|
<span>Flavour</span>
|
||||||
<select value="ecmascript" disabled>
|
<select
|
||||||
<option value="ecmascript">ECMAScript</option>
|
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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>Version</span>
|
<span>Version</span>
|
||||||
<select value="current" disabled>
|
<select
|
||||||
<option value="current">2025 syntax · current browser</option>
|
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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<fieldset className="flag-selector">
|
<fieldset className="flag-selector">
|
||||||
<legend>Flags</legend>
|
<legend>Flags</legend>
|
||||||
{FLAG_OPTIONS.map(([flag, label]) => (
|
{flavourDefinition.flags.map((flag) => (
|
||||||
<label key={flag} title={label}>
|
<label key={flag.value} title={flag.label}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${label} (${flag})`}
|
aria-label={`${flag.label} (${flag.value})`}
|
||||||
checked={flags.includes(flag)}
|
checked={flags.includes(flag.value)}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
invalidateExecution();
|
invalidateExecution();
|
||||||
invalidatePatternSyntax();
|
invalidatePatternSyntax();
|
||||||
setFlags((current) => {
|
setFlags((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (event.target.checked) {
|
if (event.target.checked) {
|
||||||
next.add(flag);
|
next.add(flag.value);
|
||||||
if (flag === "u") next.delete("v");
|
for (const group of flavourDefinition.mutuallyExclusiveFlags ??
|
||||||
if (flag === "v") next.delete("u");
|
[]) {
|
||||||
|
if (!group.includes(flag.value)) continue;
|
||||||
|
for (const incompatible of group) {
|
||||||
|
if (incompatible !== flag.value) {
|
||||||
|
next.delete(incompatible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
next.delete(flag);
|
next.delete(flag.value);
|
||||||
}
|
}
|
||||||
return FLAG_OPTIONS.map(([value]) => value).filter(
|
return flavourDefinition.flags
|
||||||
(value) => next.has(value),
|
.map((definition) => definition.value)
|
||||||
);
|
.filter((value) => next.has(value));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{flag}</span>
|
<span>{flag.value}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</fieldset>
|
</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">
|
<label className="check-control live-control">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1421,6 +1627,7 @@ export function Workbench() {
|
|||||||
runState.status === "running" ||
|
runState.status === "running" ||
|
||||||
testsRunning ||
|
testsRunning ||
|
||||||
mode === "tests" ||
|
mode === "tests" ||
|
||||||
|
mode === "corpus" ||
|
||||||
!syntax?.accepted ||
|
!syntax?.accepted ||
|
||||||
(mode === "replace" && replacementSyntax?.accepted !== true)
|
(mode === "replace" && replacementSyntax?.accepted !== true)
|
||||||
}
|
}
|
||||||
@@ -1451,8 +1658,9 @@ export function Workbench() {
|
|||||||
) : null}
|
) : null}
|
||||||
{scanAll && !flags.some((flag) => flag === "g" || flag === "y") ? (
|
{scanAll && !flags.some((flag) => flag === "g" || flag === "y") ? (
|
||||||
<p className="scan-banner">
|
<p className="scan-banner">
|
||||||
Scan all is explicit: the worker adds an internal <code>g</code> only
|
Scan all is explicit: the worker adds an internal global-iteration
|
||||||
for iteration. The pattern’s saved user flags remain unchanged.
|
flag only for this request. The pattern’s saved user flags remain
|
||||||
|
unchanged.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="workspace-toolbar">
|
<div className="workspace-toolbar">
|
||||||
@@ -1462,6 +1670,7 @@ export function Workbench() {
|
|||||||
["match", "Match"],
|
["match", "Match"],
|
||||||
["replace", "Replace"],
|
["replace", "Replace"],
|
||||||
["list", "List"],
|
["list", "List"],
|
||||||
|
["corpus", "Corpus / Apply"],
|
||||||
["tests", "Unit tests"],
|
["tests", "Unit tests"],
|
||||||
] as const
|
] as const
|
||||||
).map(([value, label]) => (
|
).map(([value, label]) => (
|
||||||
@@ -1481,11 +1690,77 @@ export function Workbench() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="workspace-actions">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => setShowReference((value) => !value)}
|
onClick={() => setShowReference((value) => !value)}
|
||||||
aria-expanded={showReference}
|
aria-expanded={showReference}
|
||||||
|
aria-controls="quick-reference-dialog"
|
||||||
>
|
>
|
||||||
Quick reference
|
Quick reference
|
||||||
</button>
|
</button>
|
||||||
@@ -1494,6 +1769,7 @@ export function Workbench() {
|
|||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => setShowCapabilities((value) => !value)}
|
onClick={() => setShowCapabilities((value) => !value)}
|
||||||
aria-expanded={showCapabilities}
|
aria-expanded={showCapabilities}
|
||||||
|
aria-controls="capabilities-dialog"
|
||||||
>
|
>
|
||||||
Capabilities
|
Capabilities
|
||||||
</button>
|
</button>
|
||||||
@@ -1526,6 +1802,199 @@ export function Workbench() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={`run-status status-${runState.status}`}
|
className={`run-status status-${runState.status}`}
|
||||||
role="status"
|
role="status"
|
||||||
@@ -1596,7 +2065,7 @@ export function Workbench() {
|
|||||||
onSelect={(node) => selectSyntaxNode(node)}
|
onSelect={(node) => selectSyntaxNode(node)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{mode !== "tests" ? (
|
{mode !== "tests" && mode !== "corpus" ? (
|
||||||
<div className="result-workspace">
|
<div className="result-workspace">
|
||||||
<section className="panel editor-panel subject-panel">
|
<section className="panel editor-panel subject-panel">
|
||||||
<header className="panel-heading">
|
<header className="panel-heading">
|
||||||
@@ -1715,6 +2184,7 @@ export function Workbench() {
|
|||||||
) : null}
|
) : null}
|
||||||
{mode === "replace" ? (
|
{mode === "replace" ? (
|
||||||
<ReplacementPanel
|
<ReplacementPanel
|
||||||
|
flavour={flavour}
|
||||||
replacement={replacement}
|
replacement={replacement}
|
||||||
onReplacementChange={changeReplacement}
|
onReplacementChange={changeReplacement}
|
||||||
syntax={replacementSyntax}
|
syntax={replacementSyntax}
|
||||||
@@ -1740,10 +2210,28 @@ export function Workbench() {
|
|||||||
sourceResultTruncated={execution?.truncated ?? false}
|
sourceResultTruncated={execution?.truncated ?? false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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" ? (
|
{mode === "tests" ? (
|
||||||
<UnitTestPanel
|
<UnitTestPanel
|
||||||
tests={tests}
|
tests={tests}
|
||||||
results={testResults}
|
results={testResults}
|
||||||
|
currentFlavour={flavour}
|
||||||
currentPattern={pattern}
|
currentPattern={pattern}
|
||||||
currentFlags={flags}
|
currentFlags={flags}
|
||||||
currentScanAll={scanAll}
|
currentScanAll={scanAll}
|
||||||
@@ -1800,10 +2288,6 @@ export function Workbench() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{showReference ? <QuickReference onInsert={changePattern} /> : null}
|
|
||||||
{showCapabilities ? (
|
|
||||||
<CapabilityPanel syntax={syntax} execution={execution} />
|
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
} from "./project.serialization";
|
} from "./project.serialization";
|
||||||
import type { RegexProjectV1 } from "./project.types";
|
import type { RegexProjectV1 } from "./project.types";
|
||||||
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
|
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 = {
|
const project: RegexProjectV1 = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -58,6 +63,55 @@ describe("project import and export", () => {
|
|||||||
expect(serialized).toContain('"subject": "42"');
|
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", () => {
|
it("preflights aggregate export size after applying privacy options", () => {
|
||||||
const escapedSubject = "\0".repeat(2 * 1024 * 1024);
|
const escapedSubject = "\0".repeat(2 * 1024 * 1024);
|
||||||
const aggregateProject: RegexProjectV1 = {
|
const aggregateProject: RegexProjectV1 = {
|
||||||
@@ -171,10 +225,39 @@ describe("project import and export", () => {
|
|||||||
).toThrow(/Test 1 subject exceeds.*UTF-8 byte limit/u);
|
).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(() =>
|
expect(() =>
|
||||||
importProjectDocument(JSON.stringify({ ...project, flavour: "pcre2" })),
|
importProjectDocument(
|
||||||
).toThrow(/only supports ECMAScript/u);
|
JSON.stringify({ ...project, flavourVersion: "ECMAScript 2026" }),
|
||||||
|
),
|
||||||
|
).toThrow(/flavour version.*unsupported/iu);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
importProjectDocument(
|
importProjectDocument(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -182,7 +265,20 @@ describe("project import and export", () => {
|
|||||||
tests: [{ ...project.tests[0], flavour: "python" }],
|
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(() =>
|
expect(() =>
|
||||||
importProjectDocument(
|
importProjectDocument(
|
||||||
JSON.stringify({ ...project, options: { ignoreCase: true } }),
|
JSON.stringify({ ...project, options: { ignoreCase: true } }),
|
||||||
@@ -214,6 +310,84 @@ describe("project import and export", () => {
|
|||||||
).toThrow(/test id.*duplicated/iu);
|
).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", () => {
|
it("validates and preserves the supported per-test timeout limit", () => {
|
||||||
const imported = importProjectDocument(
|
const imported = importProjectDocument(
|
||||||
JSON.stringify({
|
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 { RegexResourceLimits } from "../regex/execution/request-limits";
|
||||||
import type { ListTemplate } from "../regex/list/list-template";
|
import type { ListTemplate } from "../regex/list/list-template";
|
||||||
import type { RegexTestCase } from "../regex/tests/test-case.types";
|
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 {
|
export interface RegexProjectV1 {
|
||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1;
|
||||||
@@ -15,7 +18,7 @@ export interface RegexProjectV1 {
|
|||||||
readonly flavourVersion?: string;
|
readonly flavourVersion?: string;
|
||||||
readonly pattern: string;
|
readonly pattern: string;
|
||||||
readonly flags: readonly string[];
|
readonly flags: readonly string[];
|
||||||
readonly options: Readonly<Record<string, boolean | number | string>>;
|
readonly options: RegexEngineOptions;
|
||||||
readonly replacement?: string;
|
readonly replacement?: string;
|
||||||
readonly mode: WorkspaceMode;
|
readonly mode: WorkspaceMode;
|
||||||
readonly testText?: {
|
readonly testText?: {
|
||||||
|
|||||||
@@ -17,14 +17,22 @@ import {
|
|||||||
utf8ByteLength,
|
utf8ByteLength,
|
||||||
} from "../regex/execution/request-limits";
|
} from "../regex/execution/request-limits";
|
||||||
import { assertProjectDocumentWithinLimit } from "./project-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>([
|
const IMPLEMENTED_MODES = new Set<WorkspaceMode>([
|
||||||
"match",
|
"match",
|
||||||
"replace",
|
"replace",
|
||||||
"list",
|
"list",
|
||||||
|
"corpus",
|
||||||
"tests",
|
"tests",
|
||||||
]);
|
]);
|
||||||
const VALID_FLAGS = new Set("dgimsuvy".split(""));
|
|
||||||
const MAXIMUM_PATTERN_UTF16 = MAXIMUM_REGEX_TEST_PATTERN_UTF16;
|
const MAXIMUM_PATTERN_UTF16 = MAXIMUM_REGEX_TEST_PATTERN_UTF16;
|
||||||
const MAXIMUM_SUBJECT_UTF16 = MAXIMUM_REGEX_TEST_SUBJECT_UTF16;
|
const MAXIMUM_SUBJECT_UTF16 = MAXIMUM_REGEX_TEST_SUBJECT_UTF16;
|
||||||
|
|
||||||
@@ -58,44 +66,6 @@ function subjectValue(value: unknown, label: string): string {
|
|||||||
return subject;
|
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(
|
function parseTestResourceLimits(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -125,7 +95,10 @@ function parseTestResourceLimits(
|
|||||||
return { manualExecutionTimeoutMs: timeout };
|
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 (!Array.isArray(value)) throw new Error("Project tests must be an array.");
|
||||||
if (value.length > MAXIMUM_REGEX_TESTS) {
|
if (value.length > MAXIMUM_REGEX_TESTS) {
|
||||||
throw new Error(`Project contains more than ${MAXIMUM_REGEX_TESTS} 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>();
|
const testIds = new Set<string>();
|
||||||
return value.map((candidate, index) => {
|
return value.map((candidate, index) => {
|
||||||
const test = objectValue(candidate, `Test ${index + 1}`);
|
const test = objectValue(candidate, `Test ${index + 1}`);
|
||||||
const flavour = parseFlavour(test.flavour, `Test ${index + 1} flavour`);
|
const flavour = registry.parse(test.flavour, `Test ${index + 1} flavour`);
|
||||||
const options = parseOptions(test.options, `Test ${index + 1} options`);
|
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(
|
const resourceLimits = parseTestResourceLimits(
|
||||||
test.resourceLimits,
|
test.resourceLimits,
|
||||||
`Test ${index + 1} resource limits`,
|
`Test ${index + 1} resource limits`,
|
||||||
@@ -261,6 +248,29 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
|
|||||||
`Imported test ${index + 1} uses unsupported expectation ${kind}.`,
|
`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);
|
const id = stringValue(test.id, `Test ${index + 1} id`, 128);
|
||||||
if (id.length === 0) {
|
if (id.length === 0) {
|
||||||
throw new Error(`Test ${index + 1} id must not be empty.`);
|
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,
|
MAXIMUM_REGEX_TEST_NAME_UTF16,
|
||||||
),
|
),
|
||||||
enabled: test.enabled !== false,
|
enabled: test.enabled !== false,
|
||||||
flavour,
|
flavour: flavour.id,
|
||||||
...(test.flavourVersion === undefined
|
...(test.flavourVersion === undefined ? {} : { flavourVersion }),
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
flavourVersion: stringValue(
|
|
||||||
test.flavourVersion,
|
|
||||||
`Test ${index + 1} flavour version`,
|
|
||||||
128,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
pattern: stringValue(
|
pattern: stringValue(
|
||||||
test.pattern,
|
test.pattern,
|
||||||
`Test ${index + 1} pattern`,
|
`Test ${index + 1} pattern`,
|
||||||
MAXIMUM_REGEX_TEST_PATTERN_UTF16,
|
MAXIMUM_REGEX_TEST_PATTERN_UTF16,
|
||||||
),
|
),
|
||||||
flags: parseFlags(test.flags),
|
flags,
|
||||||
options,
|
options,
|
||||||
...(test.scanAll === undefined
|
...(test.scanAll === undefined
|
||||||
? {}
|
? {}
|
||||||
@@ -313,28 +315,44 @@ export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
|
|||||||
}),
|
}),
|
||||||
...(resourceLimits === undefined ? {} : { resourceLimits }),
|
...(resourceLimits === undefined ? {} : { resourceLimits }),
|
||||||
expectation: parsedExpectation,
|
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);
|
const input = objectValue(value);
|
||||||
if (input.schemaVersion !== 1) {
|
if (input.schemaVersion !== 1) {
|
||||||
throw new Error("Only regex-tools project schema version 1 is supported.");
|
throw new Error("Only regex-tools project schema version 1 is supported.");
|
||||||
}
|
}
|
||||||
const flavour = parseFlavour(input.flavour, "Project flavour");
|
const flavour = registry.parse(input.flavour, "Project flavour");
|
||||||
const options = parseOptions(input.options, "Project options");
|
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) {
|
if (input.resourceOverrides !== undefined) {
|
||||||
const overrides = objectValue(
|
const overrides = objectValue(
|
||||||
input.resourceOverrides,
|
input.resourceOverrides,
|
||||||
"Project resource overrides",
|
"Project resource overrides",
|
||||||
);
|
);
|
||||||
if (Object.keys(overrides).length > 0) {
|
if (Object.keys(overrides).length > 0) {
|
||||||
throw new Error(
|
throw new Error("Project resource overrides are not implemented.");
|
||||||
"Project resource overrides are not implemented in version 0.1.0.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
const mode = stringValue(input.mode, "Project mode", 16) as WorkspaceMode;
|
||||||
if (!IMPLEMENTED_MODES.has(mode)) {
|
if (!IMPLEMENTED_MODES.has(mode)) {
|
||||||
throw new Error(`Project mode ${mode} is not implemented in this release.`);
|
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,
|
typeof input.createdAt === "string" ? input.createdAt.slice(0, 64) : now,
|
||||||
updatedAt:
|
updatedAt:
|
||||||
typeof input.updatedAt === "string" ? input.updatedAt.slice(0, 64) : now,
|
typeof input.updatedAt === "string" ? input.updatedAt.slice(0, 64) : now,
|
||||||
flavour,
|
flavour: flavour.id,
|
||||||
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
|
flavourVersion,
|
||||||
pattern: stringValue(input.pattern, "Pattern", MAXIMUM_PATTERN_UTF16),
|
pattern: stringValue(input.pattern, "Pattern", MAXIMUM_PATTERN_UTF16),
|
||||||
flags: parseFlags(input.flags),
|
flags,
|
||||||
options,
|
options,
|
||||||
replacement:
|
replacement:
|
||||||
input.replacement === undefined
|
input.replacement === undefined
|
||||||
@@ -381,7 +399,7 @@ export function parseRegexProject(value: unknown): RegexProjectV1 {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
listTemplate: parseListTemplate(listSource),
|
listTemplate: parseListTemplate(listSource),
|
||||||
tests: parseRegexTests(input.tests ?? []),
|
tests: parseRegexTests(input.tests ?? [], registry),
|
||||||
ui: {
|
ui: {
|
||||||
live:
|
live:
|
||||||
typeof input.ui === "object" &&
|
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