diff --git a/.gitignore b/.gitignore index b88b9b1..5893d04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ release/ +.engine-build/ coverage/ playwright-report/ test-results/ @@ -9,4 +10,3 @@ test-results/ .env .env.* !.env.example - diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c805541 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +public/engines/pcre2/pcre2.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a468414..1761bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## Unreleased + +## 0.2.0 — 2026-07-27 + +- Open Quick reference and Capabilities as populated, accessible in-viewport + dialogs with keyboard dismissal, focus handling and explicit empty states. +- Add flavour-neutral version, flag, option and execution-worker registries + and expose only the ECMAScript and fully wired PCRE2 runtimes. +- Validate flavour/version/options consistently across workbench requests, + projects and test suites, including legacy schema-v1 identities. +- Add a production local-first Corpus / Apply workspace with bounded UTF-8 + multi-file and pasted-text input, sequential killable worker jobs, progress, + cancellation, explicit whole-document/independent-line semantics, preserved + line separators, bounded capture extraction and replacement previews, + per-document exact/partial summaries, content-free JSON/CSV/NDJSON reports + and privacy-gated exact-output downloads and bounded ZIP export. +- Keep corpus content and results ephemeral: project schema v1 stores only the + selected workspace mode and rejects injected corpus payloads. +- Add a deterministic, offline PCRE2 10.47 WebAssembly runtime with pinned + source/toolchain identities, provenance, licences and checksums. +- Add bounded PCRE2 compile, match and native-substitution ABI calls with + match/depth/heap caps, copied caller-owned records, cleanup on every exit, + UTF-8-to-UTF-16 range normalization and lone-surrogate rejection. +- Run PCRE2 in a dedicated killable/recoverable worker, expose its real + flavour-specific flags and options, and add lexical syntax/replacement + coverage, official quick reference, conformance and browser tests. +- Add an ABI-v3 PCRE2 automatic-callout operation with caller-owned event and + mark buffers, dual event/byte caps, native truncation stop, exact UTF-8 and + UTF-16 positions, a separate killable worker and a bounded viewer that keeps + reported fields distinct from derived movement labels. +- Add ECMAScript-scoped advisory static risk diagnostics plus cancellable + bounded growth probes and cold/warm native-engine benchmarks with median/p95, + separate timing/outcome plots, runtime identity and strict + input/output/wall limits. +- Add an ECMAScript/PCRE2 comparison orchestrator with exact per-side requests, + independent worker timeout outcomes, normalized-range match/capture + alignment, replacement differences and explicit non-comparable states. +- Add a reviewed PCRE2 10.47 8-bit C17 generator with exact UTF-8 byte arrays, + native/host limits, error handling, deterministic source identities and + compile/execute golden gates against the named toolchain. +- Add deterministic, cancellable failing-subject minimization for saved-test + failures, capture ranges, exact engine timeouts and ECMAScript/PCRE2 + mismatches, with real-worker verification, explicit budgets and honest + transform-local minimality. +- Add deterministic, seed-addressed ECMAScript positive/negative case + generation from the normalized AST, with bounded synthesis, actual-engine + verification, explicit unsupported coverage, cancellable workers and + provenance-preserving unit-test integration. +- Add an idempotent grammar-backed ECMAScript literal/control formatter with an + exact preview, separate source/candidate syntax and engine workers, capture + shape checks, applicable unit-test replay, explicit inconclusive states and a + mandatory confirmation before apply. + ## 0.1.0 — 2026-07-24 - Added the open-source ECMAScript 2025 syntax provider using regexpp 4.12.2. diff --git a/LICENSES/Emscripten-MIT.txt b/LICENSES/Emscripten-MIT.txt new file mode 100644 index 0000000..18ad35a --- /dev/null +++ b/LICENSES/Emscripten-MIT.txt @@ -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. diff --git a/LICENSES/PCRE2.txt b/LICENSES/PCRE2.txt new file mode 100644 index 0000000..f6fba35 --- /dev/null +++ b/LICENSES/PCRE2.txt @@ -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. diff --git a/README.md b/README.md index 0274a00..bf20e5b 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,45 @@ Patterns, replacement templates, test text and project files stay in the browser. There is no account, backend, upload, remote execution, telemetry, runtime CDN, remote font, or cloud-save service. -## Current milestone +## Current release — 0.2.0 -Version 0.1.0 is the first production flavour slice: ECMAScript. +The v0.2.0 release contains two real production engine slices: ECMAScript and +PCRE2 10.47. - Syntax acceptance: `@eslint-community/regexpp` 4.12.2, ECMAScript 2025 grammar; application-owned structural explanations are partial and feature-matrix tested. - Execution engine: the current browser's native ECMAScript `RegExp`. - Native and editor offsets: UTF-16 code units. +- PCRE2 execution and substitution: pinned official PCRE2 10.47 compiled to a + self-hosted 8-bit WebAssembly pack with UTF/UCP always enabled. +- PCRE2 native UTF-8 byte ranges are retained and normalized to exact editor + UTF-16 ranges; inputs with lone surrogates are rejected instead of rewritten. +- PCRE2 match-step, depth and heap limits are configurable inside hard caps. +- PCRE2 automatic callouts are copied through a separate ABI operation and + displayed from a dedicated killable trace worker under 50,000-event and + 10 MiB serialized-data hard caps. - Parsing and execution run in separate workers. - Actual execution can be terminated by killing its worker. - The worker is recreated after timeout, crash or cancellation. - Capture ranges use the engine's `d` indices result. - Named, numbered, optional, empty and repeated-final captures are represented. -- Match, replacement, typed list/extraction and unit-test modes are available. +- Match, replacement, typed list/extraction, corpus/apply and unit-test modes + are available. +- ECMAScript-specific advisory static risk findings, bounded dynamic growth + probes and cold/warm benchmarks run locally; timing and growth execution is + isolated in a separately killable worker. +- ECMAScript and PCRE2 can be compared through two exact, independently timed + requests without translating patterns, flags or replacements. Incomplete or + rejected sides are explicitly not comparable. +- The reviewed PCRE2 C17 generator emits exact UTF-8 byte arrays, configured + limits and error handling for the PCRE2 10.47 8-bit API. +- A cancellable, deterministic subject minimizer verifies saved-test failures, + capture-range failures, exact timeouts and ECMAScript/PCRE2 mismatches against + the selected real workers under explicit evaluation and wall budgets. +- Deterministic ECMAScript test-case generation samples the normalized AST from + an explicit seed, reports structural coverage and unsupported constructs, and + labels cases only after verification by the selected real engine worker. - Project JSON import/export and optional IndexedDB save are available. - Interactive test text is not persisted by default. @@ -40,39 +64,67 @@ hierarchy uses syntactic capture nesting while preserving actual engine ranges. ECMAScript exposes only the final retained capture of a repeated group; Regex Tools says so and never invents capture history. -Browser ECMAScript engines do not expose their internal backtracking trace. -Version 0.1.0 therefore presents no ECMAScript execution trace and no -educational trace disguised as one. +Browser ECMAScript exposes no actual engine trace. PCRE2 has a separate bounded +automatic-callout viewer. Its event fields are reported by PCRE2, while +“forward”, “same position” and “apparent backtrack” are visibly derived only +from adjacent reported subject positions. The callout stream is not described +as a complete record of every internal engine action. ## Workbench modes - **Match** — live highlighting, extraction tree and bounded capture table. -- **Replace** — native ECMAScript `RegExp` matches with bounded, - specification-compatible string substitution; output completeness is - reported separately from bounded per-match original/result, changed-range and - token-contribution previews that synchronize the replacement, pattern, - subject and output editors. +- **Replace** — engine-native bounded substitution for ECMAScript and PCRE2; + output completeness is reported separately from bounded per-match + original/result, changed-range and token-contribution previews that + synchronize the replacement, pattern, subject and output editors. - **List** — non-executable typed templates with text, CSV, JSON and NDJSON export; export is disabled when engine collection or bounded rendering is incomplete. +- **Corpus / Apply** — bounded multi-file UTF-8 and pasted-text input processed + sequentially in killable workers. Whole-document and independent-line + semantics are explicit; line apply preserves original separators. Progress, + cancellation, bounded capture samples, replacement previews, per-document + exact/partial summaries, content-free JSON/CSV/NDJSON reports and + privacy-gated exact-output ZIP/downloads are available. Original files are + never modified. - **Unit tests** — editable and cloneable versioned presence, count, full-match, capture, replacement, timing and expected-timeout assertions. Capture a complete current result, select only cases to rerun, filter failures, and import/export a standalone validated JSON suite; execution stays in isolated workers. -Corpus, comparison, debugging, analysis, generation, minimization, formatting -and code generation remain roadmap items. Controls for them are not exposed in -this release. +The auxiliary **PCRE trace**, ECMAScript **Analysis**, **Compare & code**, +**Generate cases**, **Minimize** and **Format** workspaces are separate from +normal matching, replacement, tests and corpus execution. Trace collection is +never included in benchmark measurements. +Comparison retains native ranges while aligning actual results by normalized +editor UTF-16 ranges. Agreement is described only as evidence for the current +subject, never as proof of pattern equivalence. +Case generation is bounded sampling, not a proof of language coverage. Version +1 supports only a complete accepted ECMAScript normalized AST; the partial +PCRE2 provider is reported as unavailable rather than passed through or +relabeled. See +[`docs/GENERATED_CASES.md`](docs/GENERATED_CASES.md). +Minimization verifies every accepted candidate against the exact selected +worker request and claims only local minimality under its documented +Unicode-scalar transforms; see +[`docs/MINIMIZATION.md`](docs/MINIMIZATION.md). +Formatting is currently ECMAScript-only and changes only parser-reported +literal slash/control representations. Apply requires independent reparse, +actual-engine current-subject/replacement comparison, every applicable exact +unit test and explicit confirmation; see +[`docs/FORMATTING.md`](docs/FORMATTING.md). ## Flags and iteration Pattern and flags are stored separately; delimiters are presentation only. -Native `g` and `y` behavior is preserved. The optional **Scan all** action is -explicit. When neither `g` nor `y` is present it adds `g` only to that execution -request, visibly records the internal flag, and does not change the saved user -flags. The `d` flag may likewise be added internally to obtain exact indices -without changing matching semantics. +ECMAScript preserves native `g` and `y`; PCRE2 offers the application-level +`g` iteration flag plus `i`, `m`, `s`, `x`, `U` and `J`. PCRE2 UTF/UCP mode is +mandatory because browser strings are Unicode and partial-byte ranges cannot be +represented safely in the editor. The optional **Scan all** action adds only an +internal iteration flag to that request and does not change saved flags. +ECMAScript may add internal `d` to obtain exact indices without changing match +semantics. Zero-length global iteration advances using the selected Unicode semantics and cannot loop forever. @@ -81,29 +133,63 @@ cannot loop forever. Important defaults: -| Limit | Default | -| ------------------------------ | -----------------: | -| Live parse debounce | 120 ms | -| Live execution debounce | 220 ms | -| Live execution timeout | 250 ms | -| Manual execution timeout | 2 s | -| Advanced maximum timeout | 10 s | -| Pattern hard limit | 64 Ki UTF-16 units | -| Interactive subject hard limit | 16 MiB | -| Replacement template | 64 Ki UTF-16 units | -| List template | 16 Ki UTF-16 units | -| Per-match replacement preview | 200 matches | -| 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 | +| Limit | Default | +| ------------------------------- | -----------------: | +| Live parse debounce | 120 ms | +| Live execution debounce | 220 ms | +| Live execution timeout | 250 ms | +| Manual execution timeout | 2 s | +| Advanced maximum timeout | 10 s | +| Pattern hard limit | 64 Ki UTF-16 units | +| Interactive subject hard limit | 16 MiB | +| Corpus documents | 256 | +| Per corpus document | 16 MiB | +| Aggregate corpus input | 256 MiB | +| Independent corpus lines | 100,000 | +| Aggregate corpus matches | 100,000 | +| Capture summaries per document | 32 | +| Samples per capture | 3 | +| Retained applied output | 64 MiB | +| Applied ZIP | 68 MiB | +| Corpus batch wall time | 5 min | +| Replacement template | 64 Ki UTF-16 units | +| List template | 16 Ki UTF-16 units | +| Per-match replacement preview | 200 matches | +| 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 “no match”. The next request creates a fresh worker. @@ -128,8 +214,12 @@ require separate explicit opt-ins for JSON export and IndexedDB save. Standalone test-suite JSON likewise omits subjects by default, is schema- and field-validated on import, and appends at most 1,000 total tests. Add, update, clone and append-import also preflight the complete in-memory suite against a -32 MiB aggregate bound. Corpus content is not supported or persisted in this -release. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8 +32 MiB aggregate bound. Added generated tests retain their validated generator +version, seed, candidate ID and intended outcome; editing one removes that +provenance. Schema v1 can remember that the corpus workspace was selected, but +corpus documents, contents, outputs and results are always ephemeral and +excluded from JSON and IndexedDB. Imports that try to inject those fields are +rejected. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8 JSON-document limit in addition to per-field limits. The latest local save is read through an `updatedAt` index cursor; loading it does not materialize the complete project store. @@ -154,6 +244,9 @@ npm run test:conformance npm run test:browser npm run engines:build npm run engines:verify +npm run engines:pcre2:build -- --source-dir /path/to/pcre2 --emcc /path/to/emcc +npm run engines:pcre2:verify +npm run engines:pcre2:install npm run build npm run toolbox:check npm run check @@ -173,17 +266,34 @@ The deterministic release ZIP can be consumed by Toolbox Portal using an exact artifact URL, manifest ID, version and SHA-256. Portal builds consume the artifact; they do not build Regex Tools source. -Version 0.1.0 has no WebAssembly. Its minimum CSP needs self-hosted scripts and -workers, not `wasm-unsafe-eval`. See -[`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md). +The PCRE2 worker loads self-hosted JavaScript and WebAssembly only. Deployment +must serve `.wasm` as `application/wasm` and allow WebAssembly compilation in +`script-src`; see [`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md). ## Flavour roadmap -The next flavour is official PCRE2 10.47 WebAssembly. A technical spike from the -signed official tag has already demonstrated Unicode matching, named captures, -substitution, automatic and explicit callouts, and match/depth/heap limits. -PCRE2 is not shipped until the application-owned bridge, byte/UTF-16 offsets, -trace caps, browser tests and source/licence material meet the acceptance gate. +PCRE2 10.47 is the second implemented flavour. Its application-owned ABI +provides bounded compilation, matching, native substitution and separate +automatic-callout tracing, copied caller-owned records, deterministic +allocation cleanup and explicit result completeness. The exact +source/toolchain build is reproducible offline and the verified pack is bundled +with its metadata, checksums and licence. + +Generated-case version 1 is ECMAScript-only. It traverses the complete +application-owned ECMAScript AST under node, depth, attempt, byte, repetition +and wall-time caps, then verifies every retained label through the actual +browser `RegExp` adapter. PCRE2 generation remains unavailable until its syntax +provider can expose a complete normalized tree. + +Pattern formatting is likewise ECMAScript-only. It consumes the complete +regexpp token model and does not reinterpret the partial PCRE2 structure. +PCRE2 formatting remains unavailable until a complete grammar-backed +implementation passes equivalent idempotence, reparse, engine and test gates. + +The first advertised generated-code target is the PCRE2 10.47 8-bit C API. +Its deterministic match and replacement fixtures compile and execute against +the exact official release. See +[`docs/COMPARISON_AND_CODEGEN.md`](docs/COMPARISON_AND_CODEGEN.md). Python, Go, Rust, .NET, Java and legacy PCRE follow only through their actual named runtimes. No flavour is emulated through JavaScript and relabelled. @@ -198,7 +308,7 @@ notices; see [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and No regex101 application source, branding, generated explanation prose, reference text or visual design is copied. RegexLib and RGXP.RU fixtures are not redistributed because a suitable fixture licence was not established. No -RegexHub fixture is shipped in v0.1.0. +RegexHub fixture is shipped in v0.2.0. ## Known limitations @@ -211,7 +321,17 @@ RegexHub fixture is shipped in v0.1.0. error node, not a fabricated multi-character span or partial provider AST. - Native JavaScript compile errors do not consistently expose a machine-readable source offset; the provider diagnostic supplies the range. -- Capture history and actual ECMAScript execution tracing are unavailable. +- Capture history is unavailable. Browser ECMAScript exposes no engine trace; + PCRE2 exposes bounded automatic callouts, not a complete internal trace. + Movement classifications are derived and labelled as such. +- Static risk heuristics and generated-input observations apply only to + ECMAScript 2025 and are advisory. They are never proof of safety or of a + pattern’s general time complexity; analysis settings and results are + ephemeral. +- PCRE2 structural explanations are deliberately partial. The application + recognizes common capture forms and PCRE2-only constructs for reference, but + the actual PCRE2 compiler is authoritative. Branch-reset capture numbering is + omitted from the explanation rather than guessed. - Capture-table rendering is paged at 200 rows while aggregate collection is bounded separately. - Explanation/extraction trees and editor decorations show bounded 2,000-node @@ -235,8 +355,17 @@ RegexHub fixture is shipped in v0.1.0. - Unit-test failure messages retain at most 2 KiB UTF-8 per case, so 1,000 results stay below the 2 MiB log budget. Oversized exact replacement results offer a lightweight `should-match` draft when one is valid and fits the suite. -- Benchmark, risk analysis, generated cases, minimization and corpus processing - are not in this release. +- Corpus input accepts recognized UTF-8 text only. It does not auto-detect + legacy encodings or recursively traverse directories. +- Corpus capture extraction is a bounded per-document summary: full match plus + at most 31 capture groups, three 160-unit samples each. Content-free reports + include counts and clipping metadata, never the samples themselves. +- Generated cases deliberately sample only ECMAScript and report + backreferences, lookaround, boundaries, complex Unicode sets and other + unsupported constraints instead of claiming exhaustive coverage. +- Pattern formatting is intentionally narrow and ECMAScript-only. Its bounded + source/candidate and unit-test validation is evidence for the checked + snapshots, not a proof of equivalence over all subjects. Architecture, security, performance, provenance and flavour details live in [`docs/`](docs/). diff --git a/SOURCE.md b/SOURCE.md index 4be6470..b55c84b 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,9 +1,10 @@ # Source identity - Project: Regex Tools -- Version: 0.1.0 +- Release version: `0.2.0` +- Release tag: `v0.2.0` - Repository: -- Release tag: `v0.1.0` +- Previous release tag: `v0.1.0` - Licence: GPL-3.0-or-later - Copyright: © 2026 Albrecht Degering @@ -19,5 +20,14 @@ runtime or build-time CDN fetch. Complete preferred-form source is the tagged repository. The release archive contains compiled static assets plus legal, source-identity and attribution documents. -No PCRE2, Python, Go, Rust, .NET, Java or legacy-PCRE runtime is included in -version 0.1.0. +The explicit offline PCRE2 build is documented in `docs/ENGINE_BUILDS.md`. +PCRE2 source is not vendored: the gate accepts only the pinned, clean official +10.47 checkout and Emscripten 6.0.4 compiler. Generated staging is ignored; the +verified runtime files, deterministic metadata, checksums and exact licence are +installed under `public/engines/pcre2/` and included in the application source +and build. + +The v0.2.0 release includes PCRE2. Python, Go, Rust, .NET, Java and legacy PCRE +remain unimplemented. The v0.1.0 release remains available under its historical +tag and artifact coordinates. A future public artifact must bump package, +manifest, tag and source identity together; it must not reuse `v0.2.0`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c8a8f39..ad8cee6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,27 +1,29 @@ # Third-party notices Regex Tools original source is GPL-3.0-or-later. The production browser bundle -also contains the compatible components below. Exact dependency resolution is -recorded in `package-lock.json`. +contains the compatible runtime components below. +Exact dependency resolution is recorded in `package-lock.json`. -| Component | Version | Licence | Shipped | Role and source | -| -------------------------------- | ------------------------------------------------------------------------ | ---------- | ----------------- | ------------------------------------------------------------------------ | -| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | Runtime | Manifest contract; | -| `@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; | -| 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; | -| `@lezer/highlight` | 1.2.3 | MIT | Runtime | Editor highlighting support; | -| React / React DOM | 19.2.7 | MIT | Runtime | User interface; | -| `fflate` | 0.8.3 | MIT | Packaging utility | Deterministic release ZIP; | -| Vite | 8.1.5 | MIT | Generated helpers | Production build; | -| Rolldown | 1.1.5 | MIT | Generated helpers | Production bundling; | +| Component | Version | Licence | Shipped | Role and source | +| -------------------------------- | ------------------------------------------------------------------------ | --------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------- | +| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | Runtime | Manifest contract; | +| `@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; | +| 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; | +| `@lezer/highlight` | 1.2.3 | MIT | Runtime | Editor highlighting support; | +| React / React DOM | 19.2.7 | MIT | Runtime | User interface; | +| `fflate` | 0.8.3 | MIT | Runtime and build dependency | Local corpus-output ZIP and deterministic release ZIP; | +| Emscripten generated runtime | 6.0.4 | MIT | Generated WebAssembly glue | PCRE2 module loader/runtime; | +| PCRE2 | 10.47 | BSD-3-Clause WITH PCRE2-exception | WebAssembly runtime | Pinned official 8-bit engine; | +| Vite | 8.1.5 | MIT | Generated helpers | Production build; | +| Rolldown | 1.1.5 | MIT | Generated helpers | Production bundling; | `@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. `recheck` 4.5.0 and `regexp-ast-analysis` 0.7.1 were inspected but deliberately -not installed or shipped. PCRE2 10.47 was used only in an external feasibility -spike and is not present in the v0.1.0 artifact. +not installed or shipped. PCRE2 source is not vendored; its generated +WebAssembly pack is shipped with exact metadata, checksums and licence. Corresponding licence texts and copyright notices are in `LICENSES/`. The release package additionally carries the exact Vite and Rolldown legal files diff --git a/docs/ANALYSIS.md b/docs/ANALYSIS.md new file mode 100644 index 0000000..88bd95f --- /dev/null +++ b/docs/ANALYSIS.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d49116..d198537 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,9 +5,32 @@ boundaries. ```text React workbench - ├─ Pattern SyntaxSupervisor → syntax.worker → Regexpp provider → normalized AST + ├─ Pattern SyntaxSupervisor → syntax.worker + │ ├─ Regexpp ECMAScript provider → normalized AST + │ └─ PCRE2 lexical provider → partial normalized structure ├─ Replacement SyntaxSupervisor → syntax.worker → typed replacement tokens - ├─ EngineSupervisor → ecmascript.worker → native RegExp → match DTOs + ├─ EngineSupervisor + │ ├─ ecmascript.worker → native RegExp → match DTOs + │ └─ pcre2.worker → PCRE2 10.47 WASM ABI → match DTOs + ├─ Pcre2TraceSupervisor → pcre2-trace.worker + │ └─ separate ABI-v3 PCRE2_AUTO_CALLOUT operation → bounded trace DTOs + ├─ AnalysisPanel + │ ├─ normalized-AST ECMAScript static heuristics + │ └─ AnalysisSupervisor → analysis.worker → native RegExp benchmark/growth probes + ├─ ComparisonOrchestrator + │ ├─ two exact syntax snapshots + independently killable engine workers + │ └─ normalized UTF-16 match/capture alignment + explicit incomplete states + ├─ PCRE2 C17 generator → validated snapshot → exact UTF-8 byte-array source + ├─ SubjectMinimizer + │ ├─ fixed syntax snapshots + exact real-engine failure or comparison oracle + │ └─ deterministic Unicode-scalar reducer → bounded local-minimal result + ├─ CaseGenerationOrchestrator + │ ├─ generation.worker → deterministic bounded ECMAScript AST candidates + │ └─ EngineSupervisor → selected actual-engine label verification + ├─ ECMAScript literal/control formatter + │ ├─ exact regexpp literal-token ranges → idempotent candidate preview + │ └─ PatternFormatValidator → paired syntax + actual-engine workers and exact tests + ├─ Corpus EngineSupervisor → sequential bounded document jobs → summaries/outputs ├─ Test SyntaxSupervisor + EngineSupervisor → isolated cancellable test runs └─ project validator / serializer / IndexedDB persistence ``` @@ -21,6 +44,59 @@ request ID and worker generation. A supervisor allows one active request, rejects stale responses, terminates on timeout/crash/cancel, and lazily creates a new generation. Timeout is a distinct error state. +PCRE2 tracing is not an `EngineSupervisor` match or replacement operation. Its +own worker compiles with `PCRE2_AUTO_CALLOUT`, copies only complete fixed +records and bounded mark bytes, and stops natively at either trace cap. +Reported callout fields retain byte positions; movement classifications are a +separate derived UI layer. Normal execution, corpus, tests and benchmarks never +run through the trace operation. + +Static analysis traverses the normalized ECMAScript tree on the UI side under +node/finding caps. Timing and generated-input growth probes use the independent +analysis worker. Timeout, cancellation or crash discards that worker; no trace +mode is benchmarked. + +Comparison is a separate orchestration layer, not an `EngineSupervisor` +operation. It runs explicit ECMAScript and PCRE2 syntax/execution snapshots, +retains each engine identity and native range model, and aligns only normalized +editor ranges. Timeout, cancellation, compile rejection and truncation remain +distinct non-comparable outcomes. Shared patterns are sent unchanged; +per-flavour ports are user-owned variants. + +The PCRE2 C generator is application-owned and never implements browser +execution. It validates one exact PCRE2 snapshot, emits user strings as +independent UTF-8 byte arrays, and states the host-level mappings that the +PCRE2 API cannot express directly. Its advertised C17 target is protected by +deterministic golden identities and an exact PCRE2 10.47 compile/execute gate. + +Subject minimization is a main-thread bounded reducer whose expensive predicate +checks stay in independently terminable engine workers. Fixed pattern syntax +is parsed once for capture metadata. The baseline and every accepted candidate +retain the exact target and engine identity. Timeout, crash, cancellation, +worker failure and incomplete output are separate outcomes. Deterministic +ddmin deletion is followed by a fixed-point local sweep over Unicode-scalar +deletion and lower-rank canonical replacement; only completion of that sweep +permits a transform-local minimality claim. + +Generated-case synthesis and verification are separate trust boundaries. The +dedicated generation worker receives only a validated, complete ECMAScript +normalized tree plus explicit settings and seed. The main-thread orchestrator +then sends each bounded candidate to `EngineSupervisor`; only the selected real +adapter can establish a `should-match` or `should-not-match` label. Worker +timeout, crash, cancellation, compile rejection, wrong outcome and ordinary +failure stay distinct. Settings/results are ephemeral, while explicitly added +unit tests retain field-validated generator provenance. + +Formatting is not an engine operation and never rewrites from heuristic text +alone. The ECMAScript formatter consumes an exact accepted regexpp snapshot and +changes only parser-reported literal/control token ranges. Its validator +reparses source and candidate separately, preserves capture shape, runs both in +separate actual-engine workers against the current replacement snapshot, then +replays every enabled test tied to the exact active pattern/version/flags/options. +Any difference, incomplete output, one-sided timeout, identity change or +inconclusive worker outcome blocks apply. The preview and observations are +ephemeral, and user confirmation is required even after all bounded gates pass. + Pattern and replacement syntax use independent supervisors. A pattern snapshot is bound to its exact pattern, ordered flags and revision; execution cannot use acceptance or capture metadata from an earlier input. Replacement output is @@ -32,10 +108,27 @@ over those actual matches, the complete replacement-token model and the bounded output. They execute no replacement callback or user code and have separate presentation and token-evaluation limits. -Native ranges are retained in the engine's declared unit. Browser editor ranges +Corpus documents are ephemeral React session state, not project state. A +dedicated engine supervisor processes one document at a time and is terminated +on cancellation, timeout or workspace exit. Batch orchestration retains only +per-document summaries and, for apply runs, bounded outputs. It enforces +document-count, per-document byte, aggregate input byte, aggregate match, +aggregate output and batch wall-time limits. Exact-output export is unavailable +if any document is partial, failed, cancelled or not run. + +Whole-document mode sends each document to the engine once. Independent-line +mode segments CRLF, CR, LF, U+2028 and U+2029 without discarding them, executes +each logical line as its own subject, and reattaches the exact original +separator during apply. This makes anchor and cross-line behavior deliberately +different rather than silently rewriting the pattern. Match DTOs are reduced to +bounded per-group participation counts and samples before the next document. + +Native ranges are retained in the engine's declared unit. PCRE2 always uses +UTF/UCP and exposes UTF-8 byte ranges; its ABI copies scalar records and names +out of WebAssembly before returning and retains no code pointer. Browser editor ranges are half-open UTF-16 code-unit ranges. Reusable converters cover UTF-8 byte and -Unicode code-point offsets for later flavours, including invalid-boundary -rejection and lone-surrogate detection. +Unicode code-point offsets, including invalid-boundary rejection and +lone-surrogate detection. The release boundary is `dist/` plus checked-in legal/source documents. The Portal consumes the resulting immutable ZIP and never imports React source. diff --git a/docs/COMPARISON_AND_CODEGEN.md b/docs/COMPARISON_AND_CODEGEN.md new file mode 100644 index 0000000..137851c --- /dev/null +++ b/docs/COMPARISON_AND_CODEGEN.md @@ -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`. diff --git a/docs/ENGINE_BUILDS.md b/docs/ENGINE_BUILDS.md index ec068a7..072ce6f 100644 --- a/docs/ENGINE_BUILDS.md +++ b/docs/ENGINE_BUILDS.md @@ -1,28 +1,74 @@ # Engine builds -Version 0.1.0 ships no WebAssembly engine. +The production application uses the browser's native `RegExp` for ECMAScript +and bundles a verified PCRE2 10.47 WebAssembly pack. Normal application builds +never download or compile an engine: the reviewed files under +`public/engines/pcre2/` are copied into `dist/` and verified there. -`npm run engines:build` verifies that this release has no external pack to -build. After the production build, `npm run engines:verify` rejects any -undeclared engine asset and confirms the packaged ECMAScript-only notice. -Neither command accesses the network. +## Rebuilding PCRE2 -## PCRE2 feasibility record +The reproducible build accepts only: -The next flavour was spiked outside the application repository against official -PCRE2 10.47: +- PCRE2 tag `pcre2-10.47`, signed tag object + `cd007b4466798f66d479d1442a407099e7c40050`, peeled commit + `f454e231fe5006dd7ff8f4693fd2b8eb94333429` and tree + `81a83a3552bd68d0ea7b7004f8bb6e7892f583ba`; +- Emscripten 6.0.4, compiler revision + `fe5be6afdff43ad58860d821fcc8572a23f92d19`, from emsdk commit + `224ec5f9f2f72f09f9ce0e26d66bae7dbd8b692f`; +- CMake 4.3.4 and Ninja 1.13.2; +- 8-bit PCRE2 with Unicode enabled and JIT, threads and filesystem disabled. -- signed tag object `cd007b4466798f66d479d1442a407099e7c40050`; -- peeled commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429`; -- licence `BSD-3-Clause WITH PCRE2-exception`; -- Emscripten 6.0.4; -- 8-bit library, Unicode enabled, JIT disabled. +The source and compiler checkouts must already exist locally and be completely +clean. No command clones, downloads or updates them: -Node and Chromium tests demonstrated version/config reporting, Unicode, -named groups, global substitution, automatic and explicit callouts, and -match/depth/heap errors. This was a technical spike only. No spike source, -binary or PCRE2 source is included in v0.1.0. +```sh +npm run engines:pcre2:build -- \ + --source-dir /absolute/path/to/pcre2 \ + --emcc /absolute/path/to/emscripten/emcc +npm run engines:pcre2:verify +npm run engines:pcre2:install +``` -Production PCRE2 support remains gated on a reviewed bridge, copied DTOs, -allocation cleanup, exact source build, UTF-8/UTF-16 mapping, trace/output caps, -worker recovery, deterministic assets, licences and browser conformance. +Use `--force` only to replace the exact ignored `.engine-build/pcre2` output. +The explicit install command first re-verifies that pack and then atomically +replaces only `public/engines/pcre2`. + +The builder verifies Git objects and critical source/compiler hashes, +sanitizes build flags, sets `SOURCE_DATE_EPOCH=1760997684`, builds serially and +emits exactly: + +- `pcre2.mjs` and `pcre2.wasm`; +- deterministic `engine-metadata.json`; +- the exact upstream `LICENSE.txt`; +- `SHA256SUMS`. + +The verifier checks the closed file set, metadata, source bridge hashes and all +checksums. It validates and instantiates WebAssembly, checks ABI/engine/config +identity, then executes native Unicode compile, bounded match and bounded +substitution smoke cases plus normal and cap-stopped automatic-callout traces. + +The immutable signed-tag object is pinned. OpenPGP trust validation remains a +release-operator step because the offline builder does not install or trust a +key. + +## Runtime boundary + +ABI version 3 has no retained native handles. Each call compiles, obtains +capture names, matches/substitutes or traces and releases its code, match data +and match context before returning. The caller supplies fixed-capacity +result/name/output/event/mark buffers. Hard maxima are 1 MiB pattern, 16 MiB +subject, 1,000 captures, 10,000 matches, 100,000 capture rows, 50,000 trace +events, 10 MiB serialized trace data and 128 MiB PCRE2 heap limit; the +workbench uses stricter pattern and replacement input limits where applicable. + +PCRE2 always runs with UTF and UCP. Native UTF-8 byte offsets are retained in +DTOs and converted only at valid code-point boundaries to half-open editor +UTF-16 ranges. Lone UTF-16 surrogates are rejected before encoding. + +Normal execution lives only in `pcre2.worker`. Instrumented +`PCRE2_AUTO_CALLOUT` collection is a separate ABI operation loaded only by +`pcre2-trace.worker`; normal match, replacement, tests, corpus and benchmarks +never call it. Each supervisor terminates its worker on timeout, crash, +cancellation or supersession and lazily creates a clean generation for the +next request. diff --git a/docs/EXECUTION_TRACES.md b/docs/EXECUTION_TRACES.md index 236ac8f..e337ad7 100644 --- a/docs/EXECUTION_TRACES.md +++ b/docs/EXECUTION_TRACES.md @@ -1,13 +1,39 @@ # Execution traces -Version 0.1.0 exposes no execution trace. +Browser ECMAScript APIs expose compilation, match, capture and replacement +results, but no V8, SpiderMonkey or JavaScriptCore execution trace. Regex Tools +does not relabel structural explanations, static findings or timing +observations as an ECMAScript engine trace. -Browser ECMAScript APIs return compilation, match, capture and replacement -results but not the runtime's internal backtracking events. Regex Tools does -not label structural explanations, static hints or adjacent-result inference -as an actual engine trace. +PCRE2 10.47 has a separate actual automatic-callout vertical. ABI version 3 +compiles the requested pattern with `PCRE2_AUTO_CALLOUT`, registers an +application callback, and copies only: -The next PCRE2 slice will use actual automatic/explicit callout events from an -application-owned bridge. Event count, serialized bytes, match/depth/heap -limits and wall-clock termination must all be enforced. Classifications such -as “apparent backtrack” will remain visibly derived from adjacent events. +- callout number; +- pattern byte position and next-item byte length; +- subject byte position; +- capture-top and capture-last numbers; +- bounded current mark text. + +These fields have `reported` provenance. Pattern and subject byte boundaries +are validated and normalized to exact editor UTF-16 positions while the native +values remain visible. + +Collection runs in `pcre2-trace.worker`, not the normal execution worker. It +uses the selected PCRE2 match, depth and heap limits plus the supervisor wall +clock. The callback retains only complete 32-byte records and mark slices under +both a 50,000-event and 10 MiB serialized-data hard cap. At a cap it returns +PCRE2’s reserved `PCRE2_ERROR_CALLOUT`, abandoning the native match; the result +retains that native status, the last complete event and an explicit truncation +flag. Timeout, cancellation or crash discards the complete worker. + +The viewer derives “forward”, “same position” and “apparent backtrack” solely +from the difference between adjacent reported subject positions. Each label +has `derived` provenance. This is useful navigation, not a claim that the +stream reports every internal action. PCRE2 start optimizations can also +conclude some requests without any callout. + +Tracing represents one exact `pcre2_match()` invocation. The application-level +`g` iteration flag is not applied, and that fact is reported. Normal match, +replacement, tests, corpus, comparisons and benchmarks never use the +instrumented trace path. diff --git a/docs/EXPLANATION_MODEL.md b/docs/EXPLANATION_MODEL.md index 37f2e16..f5ed969 100644 --- a/docs/EXPLANATION_MODEL.md +++ b/docs/EXPLANATION_MODEL.md @@ -1,9 +1,9 @@ # Explanation model -The community provider converts regexpp nodes into a stable application AST -inside the syntax worker. Nodes carry deterministic IDs, UTF-16 ranges, raw -source, capture metadata, quantifier bounds, assertion kind, support status, -provider identity and provenance. +The ECMAScript community provider converts regexpp nodes into a stable +application AST inside the syntax worker. Nodes carry deterministic IDs, +UTF-16 ranges, raw source, capture metadata, quantifier bounds, assertion kind, +support status, provider identity and provenance. Explanations are original deterministic text selected by node type. Nullable and minimum/maximum consumed-length properties are derived recursively where @@ -15,6 +15,13 @@ regexpp 4.12.2 does not expose tolerant recovery. A malformed pattern therefore gets the provider's exact diagnostic plus an application-owned error node. No partial regexpp AST is claimed. +The PCRE2 provider currently emits a deliberately partial lexical tree for +common capture forms. It does not infer branch-reset numbering or claim full +nesting; authoritative compilation, capture counts, names and result ranges +come from PCRE2 itself. + The explanation tree is structural. It is never called an actual execution trace. Selecting a node selects and scrolls its exact editor range; selecting -pattern text chooses the smallest enclosing node. +pattern text chooses the smallest enclosing node. PCRE2’s separate trace viewer +contains actual bounded automatic callouts; its movement labels remain a +distinct derived layer and are never copied into the structural explanation. diff --git a/docs/FLAVOUR_SUPPORT.md b/docs/FLAVOUR_SUPPORT.md index fe88c9e..6590c4c 100644 --- a/docs/FLAVOUR_SUPPORT.md +++ b/docs/FLAVOUR_SUPPORT.md @@ -1,20 +1,55 @@ # Flavour support -| Flavour | Syntax | Execution | Replacement | Captures | Trace | Native offsets | -| ------------ | ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | ----------- | ------------------- | -| ECMAScript | regexpp 4.12.2 grammar; explanations partial/feature-matrix tested | Current browser `RegExp`; feature-matrix tested | Native matches; bounded ECMAScript `GetSubstitution` | Named/numbered; final repeated capture; no history | Unavailable | UTF-16 | -| PCRE2 | Unavailable in release | Technical spike only; not shipped | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | -| PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | -| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points | -| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | -| Rust `regex` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | -| .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Planned UTF-16 | -| Java | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-16 | +| Flavour | Syntax | Execution | Replacement | Captures | Trace | Analysis | Generated cases | Native offsets | +| ------------ | ------------------------------------------------------------ | ------------------------------------------------------ | ---------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------- | ------------------- | +| ECMAScript | regexpp 4.12.2; explanations partial/feature-matrix tested | Current browser `RegExp`; conformance tested | Bounded ECMAScript `GetSubstitution` | Named/numbered; final repeated capture; no history | Unavailable | Experimental static heuristics + bounded native probes | Bounded normalized-AST sampling; actual-engine verified | UTF-16 | +| PCRE2 10.47 | Application lexical provider partial; compiler authoritative | Pinned 8-bit WASM; bounded/killable/conformance tested | Native bounded `pcre2_substitute()` loop | Native named/numbered records; no capture history | Bounded reported automatic callouts; derived movement labels | Unavailable; ECMAScript heuristics are never applied | Unavailable; structural syntax provider is partial | UTF-8 bytes | +| PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | +| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points | +| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | +| Rust `regex` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes | +| .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Unavailable | Unavailable | Planned UTF-16 | +| Java | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-16 | -ECMAScript tests cover `g`, `y`, `u`, internal `d`, zero-length iteration, -named groups, unmatched/empty/repeated groups, lookbehind, backreferences, -Unicode properties, astral input, replacement and result truncation. +ECMAScript tests cover its complete exposed flag matrix, internal indices, +zero-length iteration, captures, lookbehind, backreferences, Unicode properties +and bounded replacement. -Actual browser identity is displayed because ECMAScript behavior can evolve -with the runtime. Syntax acceptance and engine compilation are separate; -neither silently rewrites the pattern. +PCRE2 tests cover `g`, `i`, `m`, `s`, `x`, `U`, `J`, mandatory UTF/UCP, +branch-reset groups, recursion, `\K`, duplicate names, native substitution, +astral byte/UTF-16 ranges, zero-length iteration, compile errors, match limits +and result/output truncation. Trace tests cover reported automatic callouts, +bounded mark copying, native stop at the complete-event cap, compile ranges, +worker loading and browser rendering. + +ECMAScript static findings and generated-input timing observations are +advisory. They describe only the selected ECMAScript 2025 pattern, browser +runtime and bounded inputs; they are not proofs of safety or general +complexity. + +ECMAScript generated-case version 1 uses only a complete accepted normalized +AST, an explicit deterministic seed and bounded sampling strategies. Every +retained positive or negative label is confirmed by the actual selected +browser engine. PCRE2 generation is unavailable because its current lexical +provider is intentionally partial; ECMAScript behavior is never relabelled as +PCRE2. + +The PCRE2 explanation provider deliberately does not invent full structural +coverage. In particular, branch-reset numbering is omitted from the syntax tree +while actual capture numbers and names still come from PCRE2 results. + +ECMAScript and PCRE2 support exact-request comparison for syntax acceptance, +engine compilation, matches, captures and engine-native replacement. Results +align by normalized UTF-16 ranges while retaining each native offset model. +Timeout, cancellation, rejection and truncation are never presented as +equivalent results. + +Pattern formatting is available only for an exact accepted ECMAScript regexpp +snapshot. It canonicalizes literal slashes and raw control/line-separator +representations, then requires paired actual-engine and applicable exact-test +validation before apply. PCRE2 formatting is unavailable because the current +provider is deliberately partial; its syntax is never treated as ECMAScript. + +PCRE2 is the only current code-generation target. The reviewed C17 generator is +pinned to the PCRE2 10.47 8-bit API; other language generators remain +unavailable until they pass equivalent escaping and named-toolchain gates. diff --git a/docs/FORMATTING.md b/docs/FORMATTING.md new file mode 100644 index 0000000..9ed2a83 --- /dev/null +++ b/docs/FORMATTING.md @@ -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. diff --git a/docs/GENERATED_CASES.md b/docs/GENERATED_CASES.md new file mode 100644 index 0000000..0935037 --- /dev/null +++ b/docs/GENERATED_CASES.md @@ -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. diff --git a/docs/MINIMIZATION.md b/docs/MINIMIZATION.md new file mode 100644 index 0000000..656dc5d --- /dev/null +++ b/docs/MINIMIZATION.md @@ -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. diff --git a/docs/PARSER_PROFILES.md b/docs/PARSER_PROFILES.md index b92f89c..a00cfde 100644 --- a/docs/PARSER_PROFILES.md +++ b/docs/PARSER_PROFILES.md @@ -7,10 +7,13 @@ community ``` It uses only reviewed open-source dependencies and builds without private -registry credentials. Version 0.1.0 parses ECMAScript with regexpp 4.12.2. +registry credentials. ECMAScript uses regexpp 4.12.2. PCRE2 uses an +application-owned lexical provider for partial explanations and the actual +bundled PCRE2 compiler for authoritative acceptance. The product deliberately does not implement or reference an `@r101/parser` profile. No commercial package alias, import, tarball, credential, licence key or private CI path exists. Additional flavour syntax will be implemented incrementally as open-source providers behind the same application-owned -interface. +interface. Partial coverage is exposed as metadata and never presented as a +complete parser. diff --git a/docs/PCRE2_NEXT_SLICES.md b/docs/PCRE2_NEXT_SLICES.md new file mode 100644 index 0000000..3258385 --- /dev/null +++ b/docs/PCRE2_NEXT_SLICES.md @@ -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). diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index cbf1776..a8a56e6 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -10,6 +10,18 @@ browser, engine version, pattern and subject. The UI therefore reports observed elapsed time and exact runtime identity but makes no universal throughput or safety claim. +PCRE2 cold execution includes loading and instantiating the self-hosted +WebAssembly module inside its worker; warm requests reuse that instance. +Every request still compiles and frees its pattern so no unbounded compiled-code +cache can accumulate. Native match-step, depth and heap limits complement the +supervisor wall clock, and cancellation discards the complete worker heap. + +Automatic-callout tracing is deliberately excluded from every benchmark. It +compiles an independently instrumented PCRE2 pattern in a separate worker and +can materially change runtime work. The trace path retains at most 50,000 +complete 32-byte event records and 10 MiB total serialized event/mark bytes; +the viewer renders at most 2,000 retained events. + The production bundle separates syntax and execution workers. Interactive syntax/extraction trees and editor decoration sets render at most 2,000 items each and report both the rendered and actual totals. These are presentation @@ -62,5 +74,57 @@ representation against a byte budget before materializing or writing it. IndexedDB schema version 2 indexes `updatedAt`; latest-project loading opens one descending cursor and validates only that record instead of calling `getAll()`. -Formal cold/warm benchmarks, p95 statistics and growth charts are deferred to -the analysis milestone and will run in killable workers. +Corpus runs accept at most 256 documents, 16 MiB per document and 256 MiB of +aggregate UTF-8 input. Jobs run sequentially so only one engine request is +active at a time. A batch retains at most 100,000 matches and 64 MiB of applied +output, and stops after five minutes of aggregate wall time. Each document also +uses the selected manual timeout. Progress is reported after every document; +cancellation kills the current worker and labels all remaining documents. +Result tables contain summaries, not match/capture objects. ZIP export is +available only for an all-exact apply batch and is created asynchronously. + +Independent-line mode preflights at most 100,000 logical lines and processes +each as an isolated engine subject while preserving line separators for exact +apply output. Per document, corpus extraction retains at most 32 group summaries +(including the full match), three 160-UTF-16-unit samples per group and 100 +unique diagnostics. Output previews show 2,000 units. Content-free summary +exports are capped at 4 MiB; the applied archive is capped at 68 MiB in addition +to the 64 MiB uncompressed-output budget. + +ECMAScript analysis reports cold compilation separately from warm native +execution. The default benchmark uses three warm-up and 15 measured samples; +user caps are 100 warm-ups and 1,000 samples. It reports median and +nearest-rank p95 values plus separate timing and outcome/growth plots with the +exact runtime identity. Each sample defaults to a 2-second killable-worker +limit (10-second cap), and the aggregate analysis wall time is 30 seconds. + +Growth probes retain at most 24 steps, generate at most 1 MiB by default under +the central 16 MiB subject cap, and reject more than 10,000,000 repetitions +before string allocation. Replacement timing is skipped when match collection +is truncated or the estimated output exceeds 8 Mi UTF-16 units. Results are +observations for the chosen runtime and generated inputs, never a general +complexity proof. See [`ANALYSIS.md`](ANALYSIS.md). + +Generated-case synthesis defaults to 48 retained cases from at most 192 +candidates, 16 seeded-random variants, 64 KiB per candidate, 1 MiB aggregate +candidate bytes and 15 seconds of aggregate wall time. Every candidate then +uses a separate selected-engine request with a 500 ms default timeout. +Generation preflights UTF-8 size and repetition bounds, caps normalized-AST +traversal at 20,000 nodes / 512 levels and retains only bounded diagnostics. +Hard caps are documented in [`GENERATED_CASES.md`](GENERATED_CASES.md). + +Subject minimization accepts at most 1 MiB UTF-8, performs at most 2,000 real +engine candidate evaluations and stops after at most 60 seconds including +syntax setup. One fixed worker timeout of at most 10 seconds applies to every +candidate. A candidate starts only when that complete deadline fits in the +remaining aggregate budget. Accepted-transform history retains 200 entries; +evaluation and accepted totals remain exact. See +[`MINIMIZATION.md`](MINIMIZATION.md). + +Formatter validation uses two syntax supervisors and two actual-engine +supervisors so source/candidate outcomes cannot share compiled state. The +current subject/replacement snapshot always runs; at most 1,000 enabled tests +with the exact active identity are replayed. Test validation has a 60-second +aggregate wall budget, and a test starts only when its complete configured +worker timeout still fits. Preview details retain at most 10,000 transforms and +the UI renders the first 200. See [`FORMATTING.md`](FORMATTING.md). diff --git a/docs/PORTAL_REQUIREMENTS.md b/docs/PORTAL_REQUIREMENTS.md index c6f2530..c519ad1 100644 --- a/docs/PORTAL_REQUIREMENTS.md +++ b/docs/PORTAL_REQUIREMENTS.md @@ -1,6 +1,6 @@ # Portal requirements -Regex Tools 0.1.0 is a static nested-path-safe application. +Regex Tools is a static nested-path-safe application. Required delivery behavior: @@ -9,11 +9,17 @@ Required delivery behavior: revalidation/no-cache resources. - Hashed Vite assets may use immutable long-term caching. - CSP must allow `worker-src 'self' blob:`. -- No WebAssembly MIME rule or `wasm-unsafe-eval` is required by v0.1.0. +- `engines/pcre2/pcre2.wasm` must use `application/wasm`. +- `script-src` must permit self-hosted WebAssembly compilation, normally with + `'wasm-unsafe-eval'`; broad `'unsafe-eval'` is neither needed nor recommended. +- `engines/pcre2/pcre2.mjs`, metadata, checksums, licence and WASM should use + revalidation or a release-scoped immutable cache. They are not + content-hashed filenames and must never drift under one release identity. The Portal must consume the immutable release ZIP, verify its SHA-256, verify -manifest ID `de.add-ideas.regex-tools` and version `0.1.0`, and mount it at a +manifest ID `de.add-ideas.regex-tools` and version `0.2.0`, and mount it at a relative target such as `apps/regex/`. -When PCRE2 ships later, the smallest additional change will be -`application/wasm` delivery and `script-src 'self' 'wasm-unsafe-eval'`. +The package, manifest, tag, artifact URL and checksum must all use the v0.2.0 +identity. The historical v0.1.0 artifact coordinates remain immutable and must +not be overwritten or reused. diff --git a/docs/REFERENCE_IMPLEMENTATIONS.md b/docs/REFERENCE_IMPLEMENTATIONS.md index 8883c03..ab5fba8 100644 --- a/docs/REFERENCE_IMPLEMENTATIONS.md +++ b/docs/REFERENCE_IMPLEMENTATIONS.md @@ -8,7 +8,7 @@ Inspection date: 2026-07-24. | Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. | | Toolbox Portal | `a9c31c8986c40a0097966318e925083302e91e13` / 0.5.0 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. | | regexpp | 4.12.2; npm integrity `sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==` | MIT | Pinned ECMAScript syntax provider. Provider AST is normalized in a worker. | -| PCRE2 | signed tag 10.47; commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429` | BSD-3-Clause WITH PCRE2-exception | External technical spike only; not shipped or copied. | +| PCRE2 | signed tag 10.47; commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429` | BSD-3-Clause WITH PCRE2-exception | Pinned offline build; generated verified WebAssembly pack is shipped, source is not vendored. | | ECMAScript specification | ; living specification inspected 2026-07-24 | ECMA terms | Semantics reference; no prose copied. | | regex101 | and public parser documentation | Product/commercial terms | Product reference only. No source, branding, explanations, reference prose or visual design copied. | | `recheck` | 4.5.0 | MIT | Metadata inspected; not installed or shipped. | @@ -17,5 +17,5 @@ Inspection date: 2026-07-24. | RegexLib | Website inspected | Redistribution licence not established | No fixture copied. | | RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. | -All v0.1.0 conformance cases are project-authored. See +All v0.1.0 and v0.2.0 conformance cases are project-authored. See `tests/fixtures/README.md`. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 7fa1c86..194ecba 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -18,6 +18,9 @@ on publication failure, replaces an existing exact version only with `--force`, and produces: ```text -release/regex-tools-0.1.0.zip -release/regex-tools-0.1.0.zip.sha256 +release/regex-tools-0.2.0.zip +release/regex-tools-0.2.0.zip.sha256 ``` + +The existing `release/regex-tools-0.1.0.zip` and matching sidecar are historical +immutable artifacts. Creating v0.2.0 must not replace, rename or remove them. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9b2efd0..77ec32b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -9,6 +9,16 @@ untrusted. - A terminated worker is never reused. - Pattern, subject, replacement/list template, match, capture and output sizes are bounded. +- PCRE2 adds native match-step, depth and heap caps. Its C ABI returns only + caller-owned bounded records and releases code, match data and match context + on every exit path. +- PCRE2 automatic-callout tracing is a separate ABI and worker path. It copies + only complete fixed records and bounded mark text, stops matching at the + 50,000-event or 10 MiB serialized trace cap, and is killed on timeout, + cancellation or crash. +- PCRE2 runs only in mandatory UTF/UCP mode. Exact UTF-8 byte boundaries are + normalized to editor UTF-16 offsets; lone surrogates are rejected instead of + being silently replaced by browser encoding. - Replacement templates are strings; executable callbacks are unavailable. - Replacement/list token decorations, rows, chips, diagnostics and list evaluation work have separate presentation/operation caps; incomplete list @@ -25,14 +35,51 @@ untrusted. - Unit-test assertion diagnostics never stringify complete large values and retain at most 2 KiB UTF-8 per case. Oversized replacement output is not copied into an exact current-result draft. +- Corpus file input is local, recognized UTF-8 text only. It is bounded before + decoding, rejects NUL/binary input, runs sequentially in a dedicated + cancellable supervisor and never modifies source files. +- Corpus JSON/CSV/NDJSON summaries never contain source, capture-sample or + applied-output content. CSV cells are formula-injection protected. Local UI + previews are explicitly bounded. Applied files require an explicit + content-export acknowledgement; names are normalized against archive path + traversal and collisions. Incomplete applied output cannot be downloaded or + included in the all-document ZIP, whose bytes have a separate hard limit. +- Corpus documents, contents, outputs and results are never placed in project + JSON or IndexedDB. Schema-v1 imports that attempt to inject corpus payloads + are rejected. +- ECMAScript growth analysis preflights repetition count and UTF-8 bytes before + allocating a generated string. The analysis worker is terminated on timeout, + cancellation or crash; replacement timing also preflights its output bound. + Analysis settings and results are neither persisted nor uploaded. +- Generated-case synthesis runs in its own killable worker under AST, depth, + attempt, repetition and UTF-8 byte caps. Labels come only from bounded + requests to the selected real engine worker; a wrong outcome, timeout, crash, + cancellation or compile rejection is never converted into a verified case. + Settings, discarded candidates and unselected results are ephemeral and + never uploaded. +- Imported generated-test provenance is accepted only for the exact supported + generator id/version and must agree with the test assertion outcome. Unknown + provenance fields and future versions are rejected; editing a case removes + its provenance. +- ECMAScript formatting consumes only exact accepted regexpp literal-token + ranges and never treats PCRE2 as ECMAScript. Source/candidate syntax and + execution use separate killable workers; compile failure, truncation, + timeout asymmetry, engine-identity drift, incomplete tests or any semantic + difference fails closed. Preview/results are ephemeral and apply requires an + explicit confirmation. +- Subject minimization rejects lone surrogates, caps input at 1 MiB UTF-8, + evaluation count at 2,000 and aggregate wall time at 60 seconds. Each + predicate check runs in the selected killable engine worker. Timeout, crash, + cancellation and worker error remain distinct; truncated results fail + closed. Subjects and results are ephemeral and never uploaded or persisted. - No backend, uploads, telemetry, remote corpus URL, CDN, remote font, `eval`, `Function`, arbitrary command line or unsafe HTML rendering exists. -Version 0.1.0 needs a CSP equivalent to: +The PCRE2-enabled build needs a CSP equivalent to: ```text default-src 'self'; -script-src 'self'; +script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self'; img-src 'self' data:; @@ -44,8 +91,9 @@ base-uri 'none'; form-action 'none'; ``` -The Toolbox shell currently requires inline styles. No `unsafe-eval` or -`wasm-unsafe-eval` is required until an actual WebAssembly flavour ships. +The Toolbox shell currently requires inline styles. Corpus ZIP generation and +PCRE2 use self-hosted modules and local workers under the existing +`worker-src` policy. Broad `unsafe-eval` is not required. Report vulnerabilities privately to the repository owner before public issue details are posted. diff --git a/engines/pcre2/CMakeLists.txt b/engines/pcre2/CMakeLists.txt new file mode 100644 index 0000000..03d7312 --- /dev/null +++ b/engines/pcre2/CMakeLists.txt @@ -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" +) diff --git a/engines/pcre2/README.md b/engines/pcre2/README.md new file mode 100644 index 0000000..78e0022 --- /dev/null +++ b/engines/pcre2/README.md @@ -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). diff --git a/engines/pcre2/pcre2_bridge.c b/engines/pcre2/pcre2_bridge.c new file mode 100644 index 0000000..f5595da --- /dev/null +++ b/engines/pcre2/pcre2_bridge.c @@ -0,0 +1,910 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#define PCRE2_CODE_UNIT_WIDTH 8 + +#include "pcre2_bridge.h" + +#include +#include +#include +#include + +#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[] = "(?\\p{L}+)-(?\\d+)"; + static const uint8_t subject[] = "x Grüße-42 y"; + static const uint8_t replacement[] = "$:$"; + 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; +} diff --git a/engines/pcre2/pcre2_bridge.h b/engines/pcre2/pcre2_bridge.h new file mode 100644 index 0000000..8f6ed64 --- /dev/null +++ b/engines/pcre2/pcre2_bridge.h @@ -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 + +#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 diff --git a/eslint.config.mjs b/eslint.config.mjs index f930094..e08bf99 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ export default tseslint.config( "coverage", "test-results", "playwright-report", + ".engine-build", ], }, { diff --git a/package-lock.json b/package-lock.json index cf94e2b..22e0e3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "regex-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "regex-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { "@add-ideas/toolbox-contract": "0.2.2", @@ -18,6 +18,7 @@ "@codemirror/view": "6.43.6", "@eslint-community/regexpp": "4.12.2", "@lezer/highlight": "1.2.3", + "fflate": "0.8.3", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -35,7 +36,6 @@ "eslint": "10.7.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.2", - "fflate": "0.8.3", "globals": "17.7.0", "jsdom": "29.1.1", "prettier": "3.9.5", @@ -2442,7 +2442,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { diff --git a/package.json b/package.json index 9105b5e..26da57e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regex-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Develop, explain, test and apply regular expressions locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -32,6 +32,9 @@ "test:conformance": "vitest run tests/conformance", "test:browser": "playwright test", "engines:build": "node scripts/build-engines.mjs", + "engines:pcre2:build": "node scripts/build-engines.mjs --pcre2", + "engines:pcre2:install": "node scripts/install-pcre2-engine.mjs", + "engines:pcre2:verify": "node scripts/verify-engine-assets.mjs --pcre2-pack .engine-build/pcre2", "engines:verify": "node scripts/verify-engine-assets.mjs", "manifest:generate": "node scripts/generate-toolbox-manifest.mjs", "manifest:check": "node scripts/generate-toolbox-manifest.mjs --check", @@ -51,6 +54,7 @@ "@codemirror/view": "6.43.6", "@eslint-community/regexpp": "4.12.2", "@lezer/highlight": "1.2.3", + "fflate": "0.8.3", "react": "19.2.7", "react-dom": "19.2.7" }, @@ -68,7 +72,6 @@ "eslint": "10.7.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.2", - "fflate": "0.8.3", "globals": "17.7.0", "jsdom": "29.1.1", "prettier": "3.9.5", diff --git a/public/engines/README.md b/public/engines/README.md index a8ecff3..34b814f 100644 --- a/public/engines/README.md +++ b/public/engines/README.md @@ -1,7 +1,9 @@ # Engine assets -Regex Tools 0.1.0 executes ECMAScript through the browser's native `RegExp` -engine. It therefore ships no external engine binary or WebAssembly pack. +Regex Tools executes ECMAScript through the browser's native `RegExp` engine. +It also ships the declared, self-hosted PCRE2 10.47 WebAssembly pack in +`pcre2/`. -Future flavour packs will be built from pinned, documented open-source -revisions and placed in versioned subdirectories here. +The PCRE2 pack is built from pinned, documented open-source code and +toolchain revisions. `engine-metadata.json`, `SHA256SUMS` and `LICENSE.txt` +travel with the runtime files and are verified by the release gate. diff --git a/public/engines/pcre2/LICENSE.txt b/public/engines/pcre2/LICENSE.txt new file mode 100644 index 0000000..f6fba35 --- /dev/null +++ b/public/engines/pcre2/LICENSE.txt @@ -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. diff --git a/public/engines/pcre2/SHA256SUMS b/public/engines/pcre2/SHA256SUMS new file mode 100644 index 0000000..8ed63d6 --- /dev/null +++ b/public/engines/pcre2/SHA256SUMS @@ -0,0 +1,4 @@ +197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811 LICENSE.txt +8c7f67ad85893fcc545c40d496abcc82d1009b76c9b7bcc7bd9ab92828852d76 engine-metadata.json +d31a5f1e0839955ae8321430eafe65da8cbdef832a1245bb71ccc5bb0d4b6b9c pcre2.mjs +cc7c4b2b0038e137c86d2037e73f2302e56432cb09bc082e4f35bba221dd1889 pcre2.wasm diff --git a/public/engines/pcre2/engine-metadata.json b/public/engines/pcre2/engine-metadata.json new file mode 100644 index 0000000..8f950fe --- /dev/null +++ b/public/engines/pcre2/engine-metadata.json @@ -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 + } + ] +} diff --git a/public/engines/pcre2/pcre2.mjs b/public/engines/pcre2/pcre2.mjs new file mode 100644 index 0000000..b8f1e04 --- /dev/null +++ b/public/engines/pcre2/pcre2.mjs @@ -0,0 +1,2 @@ +async function createRegexPcre2(moduleArg={}){var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("node:module");var require=createRequire(import.meta.url)}var programArgs=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("node:path").dirname(require("node:url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}programArgs=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var runtimeInitialized=false;function getMemoryBuffer(){return wasmMemory.buffer}function updateMemoryViews(){if(HEAP8?.buffer?.resizable)return;var b=getMemoryBuffer();HEAP8=new Int8Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b)}function preRun(){}function initRuntime(){runtimeInitialized=true;wasmExports["c"]()}function postRun(){}function abort(what){what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("pcre2.wasm")}return new URL("pcre2.wasm",import.meta.url).href}function getBinarySync(file){if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP8;var getHeapMax=()=>268435456;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var HEAPU8;var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";{}Module["UTF8ToString"]=UTF8ToString;var _regex_pcre2_bridge_abi_version,_regex_pcre2_config_flags,_regex_pcre2_version,_regex_pcre2_error_message,_regex_pcre2_compile_probe,_regex_pcre2_execute,_regex_pcre2_substitute,_regex_pcre2_trace,_regex_pcre2_self_test,_malloc,_free,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_regex_pcre2_bridge_abi_version=Module["_regex_pcre2_bridge_abi_version"]=wasmExports["d"];_regex_pcre2_config_flags=Module["_regex_pcre2_config_flags"]=wasmExports["e"];_regex_pcre2_version=Module["_regex_pcre2_version"]=wasmExports["f"];_regex_pcre2_error_message=Module["_regex_pcre2_error_message"]=wasmExports["g"];_regex_pcre2_compile_probe=Module["_regex_pcre2_compile_probe"]=wasmExports["h"];_regex_pcre2_execute=Module["_regex_pcre2_execute"]=wasmExports["i"];_regex_pcre2_substitute=Module["_regex_pcre2_substitute"]=wasmExports["j"];_regex_pcre2_trace=Module["_regex_pcre2_trace"]=wasmExports["k"];_regex_pcre2_self_test=Module["_regex_pcre2_self_test"]=wasmExports["l"];_malloc=Module["_malloc"]=wasmExports["m"];_free=Module["_free"]=wasmExports["n"];memory=wasmMemory=wasmExports["b"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:_emscripten_resize_heap};async function run(){preRun();if(ABORT)return;initRuntime();postRun()}var wasmExports;wasmExports=await createWasm();await run(); +;return Module}export default createRegexPcre2; diff --git a/public/engines/pcre2/pcre2.wasm b/public/engines/pcre2/pcre2.wasm new file mode 100644 index 0000000..2dee69e Binary files /dev/null and b/public/engines/pcre2/pcre2.wasm differ diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 2322940..a915b18 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.regex-tools", "name": "Regex Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Develop, explain, test and apply regular expressions locally in the browser.", "entry": "./", "icon": "./favicon.svg", diff --git a/scripts/build-engines.mjs b/scripts/build-engines.mjs index b254aaf..6409c0e 100644 --- a/scripts/build-engines.mjs +++ b/scripts/build-engines.mjs @@ -1,8 +1,37 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + buildPcre2Pack, + parsePcre2BuildArguments, +} from "./pcre2-engine-pack.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = parsePcre2BuildArguments(process.argv.slice(2)); + +if (options.mode === "help") { + console.log(`Usage: + node scripts/build-engines.mjs + node scripts/build-engines.mjs --pcre2 --source-dir PATH --emcc PATH [--force] + +The PCRE2 command is an explicit offline build gate. It verifies exact local +source and compiler identities, writes .engine-build/pcre2, and never +downloads or publishes an engine pack. Install the verified local pack with +npm run engines:pcre2:install.`); + process.exit(0); +} + +if (options.mode === "pcre2") { + const result = await buildPcre2Pack(options, root); + console.log( + `Built and verified staged PCRE2 ${result.metadata.engineVersion} pack at ${result.output}.`, + ); + console.log( + "The verified pack remains in private staging until the explicit local install command is run.", + ); + process.exit(0); +} + const packageJson = JSON.parse( await readFile(path.join(root, "package.json"), "utf8"), ); @@ -10,15 +39,16 @@ const engineDirectory = path.join(root, "public", "engines"); const entries = (await readdir(engineDirectory)).sort(); if ( - packageJson.version !== "0.1.0" || - entries.length !== 1 || - entries[0] !== "README.md" + typeof packageJson.version !== "string" || + entries.length !== 2 || + entries[0] !== "README.md" || + entries[1] !== "pcre2" ) { throw new Error( - "The ECMAScript release must contain only the documented native-browser engine placeholder.", + "The production build requires the documented PCRE2 runtime pack.", ); } console.log( - "Regex Tools 0.1.0 uses the native browser RegExp engine; no external engine pack needs building.", + "The checked-in PCRE2 runtime pack is present; no release-time download or engine compilation is needed.", ); diff --git a/scripts/generate-toolbox-manifest.mjs b/scripts/generate-toolbox-manifest.mjs index d677ac0..2131d63 100644 --- a/scripts/generate-toolbox-manifest.mjs +++ b/scripts/generate-toolbox-manifest.mjs @@ -7,14 +7,23 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const sourcePath = join(root, "src", "toolbox", "manifest.source.json"); const outputPath = join(root, "public", "toolbox-app.json"); const packagePath = join(root, "package.json"); +const applicationVersionPath = join(root, "src", "version.ts"); const checkOnly = process.argv.includes("--check"); const source = JSON.parse(await readFile(sourcePath, "utf8")); const packageJson = JSON.parse(await readFile(packagePath, "utf8")); +const applicationVersionSource = await readFile(applicationVersionPath, "utf8"); +const applicationVersion = + /^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec( + applicationVersionSource, + )?.[1]; -if (source.version !== packageJson.version) { +if ( + source.version !== packageJson.version || + applicationVersion !== packageJson.version +) { throw new Error( - `Manifest version ${source.version} differs from package version ${packageJson.version}`, + `Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`, ); } diff --git a/scripts/install-pcre2-engine.mjs b/scripts/install-pcre2-engine.mjs new file mode 100644 index 0000000..3a07874 --- /dev/null +++ b/scripts/install-pcre2-engine.mjs @@ -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}.`, +); diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs index e3d62aa..6fe7b91 100644 --- a/scripts/package-release.mjs +++ b/scripts/package-release.mjs @@ -18,6 +18,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const zipEpoch = new Date(1980, 0, 1, 0, 0, 0); const gplVersion3TextSha256 = "fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0"; +const expectedApplicationVersion = "0.2.0"; const requiredRootFiles = [ "LICENSE", "README.md", @@ -332,13 +333,27 @@ async function collectReleaseEntries() { await readFile(path.join(root, "package.json"), "utf8"), ); if ( - packageJson.version !== "0.1.0" || + packageJson.version !== expectedApplicationVersion || packageJson.license !== "GPL-3.0-or-later" || packageJson.repository?.url !== "git+https://git.add-ideas.de/zemion/regex-tools.git" ) { throw new Error("Package version, licence or source identity drifted."); } + const sourceIdentity = await readFile(path.join(root, "SOURCE.md"), "utf8"); + if ( + !sourceIdentity.includes( + `- Release version: \`${expectedApplicationVersion}\``, + ) || + !sourceIdentity.includes( + `- Release tag: \`v${expectedApplicationVersion}\``, + ) || + !sourceIdentity.includes( + "- Repository: ", + ) + ) { + throw new Error("SOURCE.md release identity drifted."); + } const licence = await readFile(path.join(root, "LICENSE"), "utf8"); if ( !licence.includes("GNU GENERAL PUBLIC LICENSE") || @@ -371,6 +386,14 @@ async function collectReleaseEntries() { "CHANGELOG.md", "SOURCE.md", "THIRD_PARTY_NOTICES.md", + "engines/README.md", + "engines/pcre2/LICENSE.txt", + "engines/pcre2/SHA256SUMS", + "engines/pcre2/engine-metadata.json", + "engines/pcre2/pcre2.mjs", + "engines/pcre2/pcre2.wasm", + "LICENSES/Emscripten-MIT.txt", + "LICENSES/PCRE2.txt", "LICENSES/build/rolldown-1.1.5/LICENSE", "LICENSES/build/vite-8.1.5/LICENSE.md", ]) { diff --git a/scripts/pcre2-codegen-golden.test.ts b/scripts/pcre2-codegen-golden.test.ts new file mode 100644 index 0000000..7963518 --- /dev/null +++ b/scripts/pcre2-codegen-golden.test.ts @@ -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`(?\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:$" } : {}), + 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 }); + } + }, + ); +}); diff --git a/scripts/pcre2-engine-pack.mjs b/scripts/pcre2-engine-pack.mjs new file mode 100644 index 0000000..fbad0b6 --- /dev/null +++ b/scripts/pcre2-engine-pack.mjs @@ -0,0 +1,1018 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + access, + constants as fsConstants, + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const PCRE2_LOCK = Object.freeze({ + bridgeAbi: 3, + maximumPatternBytes: 1_048_576, + maximumSubjectBytes: 16_777_216, + maximumReplacementBytes: 262_144, + maximumMatches: 10_000, + maximumCaptureRows: 100_000, + maximumCaptureGroups: 1_000, + maximumTraceEvents: 50_000, + maximumTraceBytes: 10_485_760, + maximumTraceMarkBytes: 1_024, + sourceDateEpoch: 1_760_997_684, + source: Object.freeze({ + repository: "https://github.com/PCRE2Project/pcre2.git", + tag: "pcre2-10.47", + tagObject: "cd007b4466798f66d479d1442a407099e7c40050", + commit: "f454e231fe5006dd7ff8f4693fd2b8eb94333429", + tree: "81a83a3552bd68d0ea7b7004f8bb6e7892f583ba", + version: "10.47 2025-10-21", + license: "BSD-3-Clause WITH PCRE2-exception", + criticalFiles: Object.freeze({ + "CMakeLists.txt": + "74f65935e2b1120e7d7aeb4be81755e0f7a875732467ae64a9cc6ee7174fa5ba", + "LICENCE.md": + "197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811", + "src/pcre2.h.generic": + "058462dc030e845ee627e19ac36347f561f5cc44d89eaf1c0010f2b363ab68d5", + }), + }), + emscripten: Object.freeze({ + version: "6.0.4", + compilerRevision: "fe5be6afdff43ad58860d821fcc8572a23f92d19", + repositoryCommit: "224ec5f9f2f72f09f9ce0e26d66bae7dbd8b692f", + emccSha256: + "498eba9b6aacffca1c358ba340fc7b37015d7867cb211c7f2d45784ee9310f03", + emcmakeSha256: + "18c8397cf60835533e0db88d1172e04fa0b753dea68e978149d923a9e6378af3", + }), + cmakeVersion: "4.3.4", + ninjaVersion: "1.13.2", +}); + +const PACK_FILES = Object.freeze([ + "LICENSE.txt", + "SHA256SUMS", + "engine-metadata.json", + "pcre2.mjs", + "pcre2.wasm", +]); +const CHECKSUM_FILES = Object.freeze([ + "LICENSE.txt", + "engine-metadata.json", + "pcre2.mjs", + "pcre2.wasm", +]); +const BRIDGE_SOURCE_FILES = Object.freeze([ + "engines/pcre2/CMakeLists.txt", + "engines/pcre2/pcre2_bridge.c", + "engines/pcre2/pcre2_bridge.h", +]); +const EXPORTED_FUNCTIONS = Object.freeze([ + "_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", +]); + +function sha256(data) { + return createHash("sha256").update(data).digest("hex"); +} + +async function sha256File(file) { + return sha256(await readFile(file)); +} + +function commandLabel(command, arguments_) { + return [command, ...arguments_] + .map((part) => + /^[A-Za-z0-9_./:=,+-]+$/u.test(part) ? part : JSON.stringify(part), + ) + .join(" "); +} + +async function run(command, arguments_, options = {}) { + const child = spawn(command, arguments_, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout = []; + const stderr = []; + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + + const result = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + const output = Buffer.concat(stdout).toString("utf8"); + const errorOutput = Buffer.concat(stderr).toString("utf8"); + if (result.code !== 0) { + const detail = [output, errorOutput].filter(Boolean).join("\n").trim(); + throw new Error( + `${commandLabel(command, arguments_)} failed with ${ + result.signal ? `signal ${result.signal}` : `exit code ${result.code}` + }${detail ? `:\n${detail}` : "."}`, + ); + } + return { stdout: output, stderr: errorOutput }; +} + +async function assertRealDirectory(candidate, label) { + const details = await lstat(candidate).catch(() => null); + if (!details?.isDirectory() || details.isSymbolicLink()) { + throw new Error(`${label} must be a real directory: ${candidate}`); + } + return realpath(candidate); +} + +async function assertRegularFile(candidate, label) { + const details = await lstat(candidate).catch(() => null); + if (!details?.isFile() || details.isSymbolicLink()) { + throw new Error( + `${label} must be a regular, non-symlink file: ${candidate}`, + ); + } + return realpath(candidate); +} + +async function assertHash(file, expected, label) { + const actual = await sha256File(file); + if (actual !== expected) { + throw new Error( + `${label} SHA-256 mismatch: expected ${expected}, got ${actual}.`, + ); + } +} + +async function resolveExecutable(name, environment = process.env) { + const searchPath = environment.PATH ?? ""; + for (const directory of searchPath.split(path.delimiter)) { + if (!directory) continue; + const candidate = path.join(directory, name); + try { + await access(candidate, fsConstants.X_OK); + const details = await stat(candidate); + if (details.isFile()) return realpath(candidate); + } catch { + // Continue through PATH. + } + } + throw new Error(`${name} is required on PATH for the explicit PCRE2 build.`); +} + +function singleVersionLine(output) { + return output.trim().split(/\r?\n/u)[0] ?? ""; +} + +async function verifyPcre2Source(sourceDirectory) { + const source = await assertRealDirectory(sourceDirectory, "PCRE2 source"); + const git = await resolveExecutable("git"); + const status = await run( + git, + ["status", "--porcelain=v1", "--untracked-files=all"], + { cwd: source }, + ); + if (status.stdout !== "") { + throw new Error("The PCRE2 source checkout must be completely clean."); + } + + const checks = [ + [["rev-parse", "HEAD"], PCRE2_LOCK.source.commit, "PCRE2 commit"], + [["rev-parse", "HEAD^{tree}"], PCRE2_LOCK.source.tree, "PCRE2 source tree"], + [ + ["rev-parse", `refs/tags/${PCRE2_LOCK.source.tag}^{tag}`], + PCRE2_LOCK.source.tagObject, + "PCRE2 signed tag object", + ], + [ + ["rev-parse", `refs/tags/${PCRE2_LOCK.source.tag}^{commit}`], + PCRE2_LOCK.source.commit, + "PCRE2 tag target", + ], + ]; + for (const [arguments_, expected, label] of checks) { + const { stdout } = await run(git, arguments_, { cwd: source }); + const actual = stdout.trim(); + if (actual !== expected) { + throw new Error( + `${label} mismatch: expected ${expected}, got ${actual}.`, + ); + } + } + + for (const [relativePath, expected] of Object.entries( + PCRE2_LOCK.source.criticalFiles, + )) { + await assertHash( + path.join(source, relativePath), + expected, + `PCRE2 ${relativePath}`, + ); + } + return source; +} + +async function verifyEmscripten(emccFile) { + const emcc = await assertRegularFile(emccFile, "emcc"); + const emcmake = await assertRegularFile( + path.join(path.dirname(emcc), "emcmake"), + "emcmake", + ); + await assertHash(emcc, PCRE2_LOCK.emscripten.emccSha256, "emcc"); + await assertHash(emcmake, PCRE2_LOCK.emscripten.emcmakeSha256, "emcmake"); + + const version = await run(emcc, ["--version"]); + const expectedVersion = + `emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) ` + + `${PCRE2_LOCK.emscripten.version} (${PCRE2_LOCK.emscripten.compilerRevision})`; + if (singleVersionLine(version.stdout) !== expectedVersion) { + throw new Error( + `Emscripten identity mismatch: expected ${expectedVersion}, got ${singleVersionLine( + version.stdout, + )}.`, + ); + } + + const git = await resolveExecutable("git"); + const repository = ( + await run(git, ["rev-parse", "--show-toplevel"], { + cwd: path.dirname(emcc), + }) + ).stdout.trim(); + const commit = ( + await run(git, ["rev-parse", "HEAD"], { cwd: repository }) + ).stdout.trim(); + if (commit !== PCRE2_LOCK.emscripten.repositoryCommit) { + throw new Error( + `Emscripten repository mismatch: expected ${PCRE2_LOCK.emscripten.repositoryCommit}, got ${commit}.`, + ); + } + const tagTarget = ( + await run( + git, + ["rev-parse", `refs/tags/${PCRE2_LOCK.emscripten.version}^{commit}`], + { + cwd: repository, + }, + ) + ).stdout.trim(); + if (tagTarget !== PCRE2_LOCK.emscripten.repositoryCommit) { + throw new Error( + "The Emscripten 6.0.4 tag does not resolve to the pinned commit.", + ); + } + const status = await run( + git, + ["status", "--porcelain=v1", "--untracked-files=all"], + { cwd: repository }, + ); + if (status.stdout !== "") { + throw new Error("The Emscripten checkout must be completely clean."); + } + return { emcc, emcmake }; +} + +async function verifyNativeBuildTools() { + const cmake = await resolveExecutable("cmake"); + const ninja = await resolveExecutable("ninja"); + const cmakeOutput = await run(cmake, ["--version"]); + const ninjaOutput = await run(ninja, ["--version"]); + if ( + singleVersionLine(cmakeOutput.stdout) !== + `cmake version ${PCRE2_LOCK.cmakeVersion}` + ) { + throw new Error( + `The PCRE2 pack requires CMake ${PCRE2_LOCK.cmakeVersion}.`, + ); + } + if (ninjaOutput.stdout.trim() !== PCRE2_LOCK.ninjaVersion) { + throw new Error( + `The PCRE2 pack requires Ninja ${PCRE2_LOCK.ninjaVersion}.`, + ); + } + return { cmake, ninja }; +} + +function parseValue(argv, index, option) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${option} requires a path.`); + } + return value; +} + +export function parsePcre2BuildArguments(argv) { + if (argv.length === 0) return { mode: "ecmascript" }; + if (argv.length === 1 && argv[0] === "--help") return { mode: "help" }; + + let pcre2 = false; + let sourceDirectory; + let emccFile; + let force = false; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--pcre2") { + if (pcre2) throw new Error("--pcre2 may be supplied only once."); + pcre2 = true; + } else if (argument === "--source-dir") { + if (sourceDirectory) + throw new Error("--source-dir may be supplied only once."); + sourceDirectory = parseValue(argv, index, argument); + index += 1; + } else if (argument === "--emcc") { + if (emccFile) throw new Error("--emcc may be supplied only once."); + emccFile = parseValue(argv, index, argument); + index += 1; + } else if (argument === "--force") { + if (force) throw new Error("--force may be supplied only once."); + force = true; + } else { + throw new Error(`Unknown engine-build argument: ${argument}`); + } + } + if (!pcre2) { + throw new Error( + "External engine options require the explicit --pcre2 gate.", + ); + } + if (!sourceDirectory || !emccFile) { + throw new Error( + "The offline PCRE2 build requires both --source-dir and --emcc; it never downloads them.", + ); + } + return { mode: "pcre2", sourceDirectory, emccFile, force }; +} + +async function bridgeSourceFiles(root) { + return Promise.all( + BRIDGE_SOURCE_FILES.map(async (relativePath) => { + const contents = await readFile(path.join(root, relativePath)); + return { + path: relativePath, + sha256: sha256(contents), + bytes: contents.byteLength, + }; + }), + ); +} + +async function describedFile(directory, name) { + const contents = await readFile(path.join(directory, name)); + return { path: name, sha256: sha256(contents), bytes: contents.byteLength }; +} + +async function expectedMetadata(root, packDirectory) { + const files = await Promise.all( + ["LICENSE.txt", "pcre2.mjs", "pcre2.wasm"].map((name) => + describedFile(packDirectory, name), + ), + ); + return { + schemaVersion: 1, + engine: "pcre2", + engineVersion: PCRE2_LOCK.source.version, + status: "production-runtime", + bridge: { + abiVersion: PCRE2_LOCK.bridgeAbi, + maximumPatternBytes: PCRE2_LOCK.maximumPatternBytes, + maximumSubjectBytes: PCRE2_LOCK.maximumSubjectBytes, + maximumReplacementBytes: PCRE2_LOCK.maximumReplacementBytes, + maximumMatches: PCRE2_LOCK.maximumMatches, + maximumCaptureRows: PCRE2_LOCK.maximumCaptureRows, + maximumCaptureGroups: PCRE2_LOCK.maximumCaptureGroups, + maximumTraceEvents: PCRE2_LOCK.maximumTraceEvents, + maximumTraceBytes: PCRE2_LOCK.maximumTraceBytes, + maximumTraceMarkBytes: PCRE2_LOCK.maximumTraceMarkBytes, + sourceFiles: await bridgeSourceFiles(root), + exports: [...EXPORTED_FUNCTIONS], + }, + source: { + repository: PCRE2_LOCK.source.repository, + tag: PCRE2_LOCK.source.tag, + tagObjectSha1: PCRE2_LOCK.source.tagObject, + commitSha1: PCRE2_LOCK.source.commit, + treeSha1: PCRE2_LOCK.source.tree, + license: PCRE2_LOCK.source.license, + }, + toolchain: { + emscriptenVersion: PCRE2_LOCK.emscripten.version, + emscriptenCompilerRevision: PCRE2_LOCK.emscripten.compilerRevision, + emsdkCommitSha1: PCRE2_LOCK.emscripten.repositoryCommit, + cmakeVersion: PCRE2_LOCK.cmakeVersion, + ninjaVersion: PCRE2_LOCK.ninjaVersion, + }, + configuration: { + codeUnitWidth: 8, + unicode: true, + jit: false, + threads: false, + filesystem: false, + initialMemoryBytes: 16_777_216, + maximumMemoryBytes: 268_435_456, + }, + sourceDateEpoch: PCRE2_LOCK.sourceDateEpoch, + files, + }; +} + +function deterministicJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +async function expectedChecksums(packDirectory) { + const entries = await Promise.all( + CHECKSUM_FILES.map( + async (name) => + `${await sha256File(path.join(packDirectory, name))} ${name}`, + ), + ); + return `${entries.join("\n")}\n`; +} + +function equalJson(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +async function smokePcre2Pack(packDirectory) { + const wasm = await readFile(path.join(packDirectory, "pcre2.wasm")); + if ( + wasm.byteLength < 8 || + !wasm.subarray(0, 4).equals(Buffer.from([0x00, 0x61, 0x73, 0x6d])) || + !WebAssembly.validate(wasm) + ) { + throw new Error("pcre2.wasm is not a valid WebAssembly module."); + } + + const moduleUrl = `${pathToFileURL(path.join(packDirectory, "pcre2.mjs")).href}?sha256=${sha256( + await readFile(path.join(packDirectory, "pcre2.mjs")), + )}`; + const imported = await import(moduleUrl); + if (typeof imported.default !== "function") { + throw new Error("pcre2.mjs does not export its Emscripten factory."); + } + const diagnostics = []; + const module = await imported.default({ + locateFile(name) { + return path.join(packDirectory, name); + }, + print() {}, + printErr(line) { + diagnostics.push(String(line)); + }, + }); + + if (module._regex_pcre2_bridge_abi_version() !== PCRE2_LOCK.bridgeAbi) { + throw new Error("The WebAssembly bridge ABI version is incorrect."); + } + if (module._regex_pcre2_config_flags() !== 1) { + throw new Error( + "The WebAssembly pack must have Unicode enabled and JIT disabled.", + ); + } + if ( + module.UTF8ToString(module._regex_pcre2_version()) !== + PCRE2_LOCK.source.version + ) { + throw new Error("The WebAssembly PCRE2 version is incorrect."); + } + if (module._regex_pcre2_self_test() !== 0) { + throw new Error( + `The WebAssembly bridge self-test failed: ${diagnostics.join("\n")}`, + ); + } + + const pattern = new TextEncoder().encode("(?\\p{L}+)-(?\\d+)"); + const patternPointer = module._malloc(pattern.byteLength); + const resultPointer = module._malloc(20); + if (!patternPointer || !resultPointer) { + if (patternPointer) module._free(patternPointer); + if (resultPointer) module._free(resultPointer); + throw new Error("The WebAssembly smoke test could not allocate memory."); + } + try { + module.HEAPU8.set(pattern, patternPointer); + module.HEAPU8.fill(0, resultPointer, resultPointer + 20); + const status = module._regex_pcre2_compile_probe( + patternPointer, + pattern.byteLength, + 0x20 | 0x40, + resultPointer, + ); + const result = new DataView(module.HEAPU8.buffer, resultPointer, 20); + if ( + status !== 0 || + result.getInt32(0, true) !== 0 || + result.getUint32(8, true) !== 2 || + result.getUint32(12, true) !== 2 + ) { + throw new Error("The WebAssembly compile-probe result is incorrect."); + } + + module.HEAPU8.fill(0, resultPointer, resultPointer + 20); + const unsupportedStatus = module._regex_pcre2_compile_probe( + patternPointer, + pattern.byteLength, + 0x80000000, + resultPointer, + ); + if (unsupportedStatus !== -10002 || result.getInt32(0, true) !== -10002) { + throw new Error("The WebAssembly bridge accepted an unknown option bit."); + } + + module.HEAPU8[patternPointer] = 0x28; + module.HEAPU8.fill(0, resultPointer, resultPointer + 20); + const invalidStatus = module._regex_pcre2_compile_probe( + patternPointer, + 1, + 0, + resultPointer, + ); + if ( + invalidStatus <= 0 || + result.getInt32(0, true) !== invalidStatus || + result.getUint32(4, true) > 1 + ) { + throw new Error( + "The WebAssembly bridge did not preserve the compile error.", + ); + } + + if ( + module._regex_pcre2_compile_probe(0, 1, 0, resultPointer) !== -10001 || + module._regex_pcre2_compile_probe( + patternPointer, + PCRE2_LOCK.maximumPatternBytes + 1, + 0, + resultPointer, + ) !== -10003 + ) { + throw new Error("The WebAssembly bridge input limits are not enforced."); + } + } finally { + module._free(resultPointer); + module._free(patternPointer); + } + + const runPattern = new TextEncoder().encode("(?😀)|(?\\p{L}+)"); + const runSubject = new TextEncoder().encode("x😀 Grüße"); + const runPointers = []; + const allocate = (bytes) => { + const pointer = module._malloc(Math.max(1, bytes)); + if (!pointer) throw new Error("The PCRE2 runtime smoke allocation failed."); + runPointers.push(pointer); + return pointer; + }; + try { + const runPatternPointer = allocate(runPattern.byteLength); + const runSubjectPointer = allocate(runSubject.byteLength); + const limitsPointer = allocate(20); + const recordsPointer = allocate(20 * 16); + const namesPointer = allocate(10 * 12); + const nameBytesPointer = allocate(256); + const runResultPointer = allocate(60); + module.HEAPU8.set(runPattern, runPatternPointer); + module.HEAPU8.set(runSubject, runSubjectPointer); + let view = new DataView(module.HEAPU8.buffer); + view.setUint32(limitsPointer, 10, true); + view.setUint32(limitsPointer + 4, 100, true); + view.setUint32(limitsPointer + 8, 1_000_000, true); + view.setUint32(limitsPointer + 12, 1_000, true); + view.setUint32(limitsPointer + 16, 32_768, true); + module.HEAPU8.fill(0, runResultPointer, runResultPointer + 60); + const runStatus = module._regex_pcre2_execute( + runPatternPointer, + runPattern.byteLength, + runSubjectPointer, + runSubject.byteLength, + 0x20 | 0x40 | 0x100, + limitsPointer, + recordsPointer, + 20, + namesPointer, + 10, + nameBytesPointer, + 256, + runResultPointer, + ); + view = new DataView(module.HEAPU8.buffer); + if ( + runStatus !== 0 || + view.getInt32(runResultPointer, true) !== 0 || + view.getUint32(runResultPointer + 12, true) !== 2 || + view.getUint32(runResultPointer + 20, true) !== 3 || + view.getUint32(runResultPointer + 24, true) !== 9 || + view.getUint32(recordsPointer + 3 * 16 + 8, true) !== 1 || + view.getUint32(recordsPointer + 3 * 16 + 12, true) !== 5 + ) { + throw new Error("The WebAssembly bounded-match smoke result is wrong."); + } + + const replacementPattern = new TextEncoder().encode("(?\\p{L}+)"); + const replacementSubject = new TextEncoder().encode("Grüße 42"); + const replacement = new TextEncoder().encode("${word}!"); + const replacementPatternPointer = allocate(replacementPattern.byteLength); + const replacementSubjectPointer = allocate(replacementSubject.byteLength); + const replacementPointer = allocate(replacement.byteLength); + const outputPointer = allocate(65); + module.HEAPU8.set(replacementPattern, replacementPatternPointer); + module.HEAPU8.set(replacementSubject, replacementSubjectPointer); + module.HEAPU8.set(replacement, replacementPointer); + module.HEAPU8.fill(0, runResultPointer, runResultPointer + 60); + const substituteStatus = module._regex_pcre2_substitute( + replacementPatternPointer, + replacementPattern.byteLength, + replacementSubjectPointer, + replacementSubject.byteLength, + replacementPointer, + replacement.byteLength, + 0x20 | 0x40 | 0x100, + limitsPointer, + recordsPointer, + 20, + namesPointer, + 10, + nameBytesPointer, + 256, + outputPointer, + 64, + runResultPointer, + ); + view = new DataView(module.HEAPU8.buffer); + const outputLength = view.getUint32(runResultPointer + 48, true); + const output = new TextDecoder().decode( + module.HEAPU8.slice(outputPointer, outputPointer + outputLength), + ); + if ( + substituteStatus !== 0 || + output !== "Grüße! 42" || + view.getUint32(runResultPointer + 52, true) !== 1 || + view.getUint32(runResultPointer + 56, true) !== 0 + ) { + throw new Error( + "The WebAssembly bounded-substitution smoke result is wrong.", + ); + } + module.HEAPU8.fill(0, runResultPointer, runResultPointer + 60); + const missingOutputStatus = module._regex_pcre2_substitute( + replacementPatternPointer, + replacementPattern.byteLength, + replacementSubjectPointer, + replacementSubject.byteLength, + replacementPointer, + replacement.byteLength, + 0x20 | 0x40, + limitsPointer, + recordsPointer, + 20, + namesPointer, + 10, + nameBytesPointer, + 256, + 0, + 0, + runResultPointer, + ); + if ( + missingOutputStatus !== -10001 || + view.getInt32(runResultPointer, true) !== -10001 + ) { + throw new Error( + "The WebAssembly substitution bridge accepted a missing NUL byte.", + ); + } + + const traceEventsPointer = allocate(64 * 32); + const traceMarksPointer = allocate(256); + const traceResultPointer = allocate(52); + view.setUint32(limitsPointer, 64, true); + view.setUint32(limitsPointer + 4, 4_096, true); + module.HEAPU8.fill(0, traceResultPointer, traceResultPointer + 52); + const traceStatus = module._regex_pcre2_trace( + runPatternPointer, + runPattern.byteLength, + runSubjectPointer, + runSubject.byteLength, + 0x20 | 0x40, + limitsPointer, + traceEventsPointer, + 64, + traceMarksPointer, + 256, + traceResultPointer, + ); + view = new DataView(module.HEAPU8.buffer); + const traceEventCount = view.getUint32(traceResultPointer + 20, true); + if ( + traceStatus !== 0 || + view.getInt32(traceResultPointer, true) !== 0 || + view.getInt32(traceResultPointer + 12, true) <= 0 || + traceEventCount === 0 || + view.getUint32(traceResultPointer + 24, true) !== traceEventCount || + view.getUint32(traceResultPointer + 36, true) !== 0 || + view.getUint32(traceResultPointer + 44, true) !== traceEventCount - 1 || + view.getUint32(traceResultPointer + 48, true) !== 1 + ) { + throw new Error("The WebAssembly automatic-callout trace is wrong."); + } + + view.setUint32(limitsPointer, 1, true); + module.HEAPU8.fill(0, traceResultPointer, traceResultPointer + 52); + const boundedTraceStatus = module._regex_pcre2_trace( + runPatternPointer, + runPattern.byteLength, + runSubjectPointer, + runSubject.byteLength, + 0x20 | 0x40, + limitsPointer, + traceEventsPointer, + 64, + traceMarksPointer, + 256, + traceResultPointer, + ); + if ( + boundedTraceStatus !== 0 || + view.getInt32(traceResultPointer, true) !== 0 || + view.getInt32(traceResultPointer + 12, true) !== -37 || + view.getUint32(traceResultPointer + 20, true) !== 1 || + view.getUint32(traceResultPointer + 24, true) !== 2 || + view.getUint32(traceResultPointer + 36, true) !== 1 || + view.getUint32(traceResultPointer + 44, true) !== 0 + ) { + throw new Error("The WebAssembly trace cap is not authoritative."); + } + } finally { + for (const pointer of runPointers.reverse()) module._free(pointer); + } +} + +export async function verifyPcre2Pack(packDirectory, root) { + const repositoryRoot = await assertRealDirectory( + root, + "Regex Tools repository", + ); + const pack = await assertRealDirectory(packDirectory, "PCRE2 pack"); + const entries = (await readdir(pack, { withFileTypes: true })).sort( + (left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); + if ( + entries.length !== PACK_FILES.length || + entries.some( + (entry, index) => + entry.name !== PACK_FILES[index] || + !entry.isFile() || + entry.isSymbolicLink(), + ) + ) { + throw new Error( + `The staged PCRE2 pack must contain only: ${PACK_FILES.join(", ")}.`, + ); + } + + await assertHash( + path.join(pack, "LICENSE.txt"), + PCRE2_LOCK.source.criticalFiles["LICENCE.md"], + "staged PCRE2 licence", + ); + const moduleText = await readFile(path.join(pack, "pcre2.mjs"), "utf8"); + if ( + moduleText.includes("sourceMappingURL") || + moduleText.includes("/mnt/") || + moduleText.includes("/home/") + ) { + throw new Error("pcre2.mjs leaks a build path or source-map reference."); + } + + let metadata; + try { + metadata = JSON.parse( + await readFile(path.join(pack, "engine-metadata.json"), "utf8"), + ); + } catch (error) { + throw new Error("engine-metadata.json is not valid JSON.", { + cause: error, + }); + } + const expected = await expectedMetadata(repositoryRoot, pack); + if (!equalJson(metadata, expected)) { + throw new Error( + "The staged PCRE2 metadata does not match the pinned build contract.", + ); + } + const checksums = await readFile(path.join(pack, "SHA256SUMS"), "utf8"); + if (checksums !== (await expectedChecksums(pack))) { + throw new Error("The staged PCRE2 SHA256SUMS file is incorrect."); + } + await smokePcre2Pack(pack); + return metadata; +} + +export async function installPcre2Pack(packDirectory, root) { + const repositoryRoot = await assertRealDirectory( + root, + "Regex Tools repository", + ); + const sourcePack = await assertRealDirectory( + packDirectory, + "verified PCRE2 pack", + ); + const metadata = await verifyPcre2Pack(sourcePack, repositoryRoot); + const engineRoot = await assertRealDirectory( + path.join(repositoryRoot, "public", "engines"), + "public engine directory", + ); + const target = path.join(engineRoot, "pcre2"); + const stage = await mkdtemp(path.join(engineRoot, ".pcre2-install-")); + const targetDetails = await lstat(target).catch(() => null); + if ( + targetDetails && + (!targetDetails.isDirectory() || targetDetails.isSymbolicLink()) + ) { + await rm(stage, { recursive: true, force: true }); + throw new Error("public/engines/pcre2 must be a real directory."); + } + try { + for (const name of PACK_FILES) { + await copyFile( + path.join(sourcePack, name), + path.join(stage, name), + fsConstants.COPYFILE_EXCL, + ); + } + await verifyPcre2Pack(stage, repositoryRoot); + if (targetDetails) { + const backup = path.join(engineRoot, `.pcre2-replaced-${process.pid}`); + await rename(target, backup); + try { + await rename(stage, target); + } catch (error) { + await rename(backup, target); + throw error; + } + await rm(backup, { recursive: true }); + } else { + await rename(stage, target); + } + return { output: target, metadata }; + } finally { + await rm(stage, { recursive: true, force: true }); + } +} + +function cleanBuildEnvironment() { + const environment = { ...process.env }; + for (const variable of [ + "CFLAGS", + "CPPFLAGS", + "CXXFLAGS", + "DESTDIR", + "EMCC_CFLAGS", + "EMMAKEN_CFLAGS", + "LDFLAGS", + ]) { + delete environment[variable]; + } + environment.LANG = "C"; + environment.LC_ALL = "C"; + environment.SOURCE_DATE_EPOCH = String(PCRE2_LOCK.sourceDateEpoch); + environment.TZ = "UTC"; + environment.ZERO_AR_DATE = "1"; + return environment; +} + +async function ensurePrivateBuildRoot(root) { + const buildRoot = path.join(root, ".engine-build"); + const existing = await lstat(buildRoot).catch(() => null); + if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) { + throw new Error(".engine-build must be a real directory."); + } + await mkdir(buildRoot, { recursive: true }); + return realpath(buildRoot); +} + +function assertGeneratedChild(buildRoot, candidate) { + const relative = path.relative(buildRoot, candidate); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error( + `Refusing to remove a path outside .engine-build: ${candidate}`, + ); + } +} + +export async function buildPcre2Pack(options, root) { + const repositoryRoot = await assertRealDirectory( + root, + "Regex Tools repository", + ); + const source = await verifyPcre2Source(options.sourceDirectory); + const { emcmake } = await verifyEmscripten(options.emccFile); + const { cmake, ninja } = await verifyNativeBuildTools(); + const buildRoot = await ensurePrivateBuildRoot(repositoryRoot); + const output = path.join(buildRoot, "pcre2"); + const outputDetails = await lstat(output).catch(() => null); + if (outputDetails && !options.force) { + throw new Error( + `${output} already exists; pass --force to replace that exact staged pack.`, + ); + } + + const work = await mkdtemp(path.join(buildRoot, ".pcre2-work-")); + const stage = await mkdtemp(path.join(buildRoot, ".pcre2-stage-")); + assertGeneratedChild(buildRoot, work); + assertGeneratedChild(buildRoot, stage); + const environment = cleanBuildEnvironment(); + try { + await run( + emcmake, + [ + cmake, + "-S", + path.join(repositoryRoot, "engines", "pcre2"), + "-B", + work, + "-G", + "Ninja", + "-DCMAKE_BUILD_TYPE=Release", + `-DCMAKE_MAKE_PROGRAM=${ninja}`, + `-DPCRE2_SOURCE_DIR=${source}`, + `-DREGEX_PCRE2_OUTPUT_DIR=${stage}`, + ], + { cwd: repositoryRoot, env: environment }, + ); + await run( + cmake, + ["--build", work, "--target", "regex-pcre2-pack", "--parallel", "1"], + { + cwd: repositoryRoot, + env: environment, + }, + ); + + const generatedEntries = (await readdir(stage)).sort(); + if ( + generatedEntries.length !== 2 || + generatedEntries[0] !== "pcre2.mjs" || + generatedEntries[1] !== "pcre2.wasm" + ) { + throw new Error( + `Emscripten produced unexpected files: ${generatedEntries.join(", ") || "(none)"}.`, + ); + } + await copyFile( + path.join(source, "LICENCE.md"), + path.join(stage, "LICENSE.txt"), + fsConstants.COPYFILE_EXCL, + ); + const metadata = await expectedMetadata(repositoryRoot, stage); + await writeFile( + path.join(stage, "engine-metadata.json"), + deterministicJson(metadata), + { encoding: "utf8", flag: "wx" }, + ); + await writeFile( + path.join(stage, "SHA256SUMS"), + await expectedChecksums(stage), + { encoding: "utf8", flag: "wx" }, + ); + await verifyPcre2Pack(stage, repositoryRoot); + + if (outputDetails) { + const backup = path.join(buildRoot, `.pcre2-replaced-${process.pid}`); + assertGeneratedChild(buildRoot, backup); + await rename(output, backup); + try { + await rename(stage, output); + } catch (error) { + await rename(backup, output); + throw error; + } + await rm(backup, { recursive: true }); + } else { + await rename(stage, output); + } + return { output, metadata }; + } finally { + await rm(work, { recursive: true, force: true }); + await rm(stage, { recursive: true, force: true }); + } +} diff --git a/scripts/pcre2-engine-pack.test.ts b/scripts/pcre2-engine-pack.test.ts new file mode 100644 index 0000000..28294fc --- /dev/null +++ b/scripts/pcre2-engine-pack.test.ts @@ -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", + ); + }); +}); diff --git a/scripts/serve-test.mjs b/scripts/serve-test.mjs index f888692..90db867 100644 --- a/scripts/serve-test.mjs +++ b/scripts/serve-test.mjs @@ -13,6 +13,7 @@ const mediaTypes = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".svg", "image/svg+xml"], [".wasm", "application/wasm"], diff --git a/scripts/verify-engine-assets.mjs b/scripts/verify-engine-assets.mjs index 363ee04..bacda69 100644 --- a/scripts/verify-engine-assets.mjs +++ b/scripts/verify-engine-assets.mjs @@ -1,8 +1,25 @@ import { lstat, readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { verifyPcre2Pack } from "./pcre2-engine-pack.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const arguments_ = process.argv.slice(2); + +if (arguments_.length > 0) { + if (arguments_.length !== 2 || arguments_[0] !== "--pcre2-pack") { + throw new Error( + "Usage: node scripts/verify-engine-assets.mjs [--pcre2-pack PATH]", + ); + } + const pack = path.resolve(root, arguments_[1]); + const metadata = await verifyPcre2Pack(pack, root); + console.log( + `Verified staged PCRE2 ${metadata.engineVersion} pack and WebAssembly bridge smoke test.`, + ); + process.exit(0); +} + const engineDirectory = path.join(root, "dist", "engines"); const details = await lstat(engineDirectory); if (details.isSymbolicLink() || !details.isDirectory()) { @@ -14,21 +31,21 @@ const entries = (await readdir(engineDirectory, { withFileTypes: true })).sort( left.name < right.name ? -1 : left.name > right.name ? 1 : 0, ); if ( - entries.length !== 1 || + entries.length !== 2 || !entries[0]?.isFile() || - entries[0].name !== "README.md" + entries[0].name !== "README.md" || + !entries[1]?.isDirectory() || + entries[1].name !== "pcre2" ) { throw new Error( - "Regex Tools 0.1.0 must not ship an undeclared external engine pack.", + "The production build must contain only README.md and the declared PCRE2 pack.", ); } const notice = await readFile(path.join(engineDirectory, "README.md"), "utf8"); -if ( - !notice.includes("native `RegExp`") || - !notice.includes("no external engine") -) { +if (!notice.includes("native `RegExp`") || !notice.includes("PCRE2 10.47")) { throw new Error("The engine asset notice does not describe this release."); } +await verifyPcre2Pack(path.join(engineDirectory, "pcre2"), root); -console.log("Verified ECMAScript-only engine assets (no WebAssembly pack)."); +console.log("Verified native ECMAScript and bundled PCRE2 engine assets."); diff --git a/src/components/AnalysisPanel.css b/src/components/AnalysisPanel.css new file mode 100644 index 0000000..2408fb1 --- /dev/null +++ b/src/components/AnalysisPanel.css @@ -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; + } +} diff --git a/src/components/AnalysisPanel.test.tsx b/src/components/AnalysisPanel.test.tsx new file mode 100644 index 0000000..d89a369 --- /dev/null +++ b/src/components/AnalysisPanel.test.tsx @@ -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 { + 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[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( + 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( + 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((_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(); + }); +}); diff --git a/src/components/AnalysisPanel.tsx b/src/components/AnalysisPanel.tsx new file mode 100644 index 0000000..6230986 --- /dev/null +++ b/src/components/AnalysisPanel.tsx @@ -0,0 +1,1245 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + DEFAULT_BENCHMARK_SETTINGS, + DEFAULT_GROWTH_SETTINGS, +} from "../regex/analysis/analysis-limits"; +import { + runGrowthAnalysis, + runRegexBenchmark, +} from "../regex/analysis/analysis-runner"; +import { + AnalysisSupervisor, + type AnalysisWorkerClient, +} from "../regex/analysis/AnalysisSupervisor"; +import type { + AnalysisProgress, + GrowthAnalysisResult, + GrowthSettings, + MetricStatistics, + RegexBenchmarkResult, + RegexRiskFinding, +} from "../regex/analysis/analysis.types"; +import { analyseStaticRisk } from "../regex/analysis/static-risk"; +import { + DEFAULT_REGEX_LIMITS, + utf8ByteLength, +} from "../regex/execution/request-limits"; +import type { RegexFlavourId } from "../regex/model/flavour"; +import type { RegexSyntaxResult, SourceRange } from "../regex/model/syntax"; +import "./AnalysisPanel.css"; + +type AnalysisView = "risk" | "benchmark"; +type ActiveRun = "benchmark" | "growth"; + +export interface AnalysisPanelProps { + readonly active: boolean; + readonly flavour: RegexFlavourId; + readonly pattern: string; + readonly flags: readonly string[]; + readonly subject: string; + readonly replacement: string; + readonly scanAll: boolean; + readonly syntax?: RegexSyntaxResult; + readonly onClose?: () => void; + readonly onSelectPatternRange?: (range: SourceRange) => void; + readonly createSupervisor?: () => AnalysisWorkerClient; +} + +function formatMilliseconds(value: number | undefined): string { + if (value === undefined) return "—"; + if (value > 0 && value < 0.01) return "<0.01 ms"; + return `${value.toFixed(2)} ms`; +} + +function formatThroughput(value: number | undefined): string { + if (value === undefined) return "—"; + return `${(value / (1024 * 1024)).toFixed(2)} MiB/s`; +} + +function metricValue( + statistic: MetricStatistics | undefined, + key: "minimum" | "median" | "p95" | "maximum", + throughput: boolean, +): string { + const value = statistic?.[key]; + return throughput ? formatThroughput(value) : formatMilliseconds(value); +} + +interface MetricRow { + readonly label: string; + readonly cold?: number; + readonly warm?: MetricStatistics; + readonly throughput?: boolean; + readonly unavailableReason?: string; +} + +function BenchmarkTable({ result }: { readonly result: RegexBenchmarkResult }) { + const cold = result.coldSample; + const rows: readonly MetricRow[] = [ + { + label: "Worker cold start", + cold: result.coldStartMs, + }, + { + label: "Compile", + cold: cold?.compileMs, + warm: result.warmStatistics.compileMs, + }, + { + label: "First match", + cold: cold?.firstMatchMs, + warm: result.warmStatistics.firstMatchMs, + }, + { + label: "All matches", + cold: cold?.allMatchesMs, + warm: result.warmStatistics.allMatchesMs, + }, + { + label: "Replacement", + cold: cold?.replacementMs, + warm: result.warmStatistics.replacementMs, + unavailableReason: cold?.replacementSkippedReason, + }, + { + label: "All-match throughput", + cold: cold?.throughputBytesPerSecond, + warm: result.warmStatistics.throughputBytesPerSecond, + throughput: true, + }, + ]; + + return ( +
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
MetricCold / first useWarm minimumWarm medianWarm p95Warm maximumSamples
{row.label} + {row.throughput + ? formatThroughput(row.cold) + : formatMilliseconds(row.cold)} + + {metricValue(row.warm, "minimum", row.throughput === true)} + + {metricValue(row.warm, "median", row.throughput === true)} + {metricValue(row.warm, "p95", row.throughput === true)} + {metricValue(row.warm, "maximum", row.throughput === true)} + {row.warm?.count.toLocaleString() ?? "—"}
+
+ ); +} + +function BenchmarkSummary({ + result, +}: { + readonly result: RegexBenchmarkResult; +}) { + return ( +
+
+
+

Actual native engine · bounded worker

+

Benchmark result

+
+ + {result.status.replaceAll("-", " ")} + +
+
+
+
Engine
+
+ {result.identity + ? `${result.identity.engineName} · ${result.identity.engineVersion}` + : "Worker identity unavailable"} +
+
+
+
Subject
+
+ {result.coldSample + ? `${result.coldSample.subjectBytes.toLocaleString()} bytes · ${result.coldSample.subjectUtf16.toLocaleString()} UTF-16 units` + : "No completed cold sample"} +
+
+
+
Output count
+
+ {result.coldSample + ? `${result.coldSample.matchCount.toLocaleString()} matches${ + result.coldSample.matchCollectionTruncated + ? " · bounded prefix" + : "" + }` + : "Unavailable"} +
+
+
+
Sample count
+
+ {result.completedMeasuredIterations.toLocaleString()} measured ·{" "} + {result.completedWarmups.toLocaleString()} warm-up +
+
+
+
Wall time
+
{formatMilliseconds(result.wallTimeMs)}
+
+
+
Effective flags
+
+ {result.coldSample?.effectiveFlags || "(none)"} +
+
+
+ {result.stoppedReason ? ( +

{result.stoppedReason}

+ ) : null} + +
    + {result.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ); +} + +function RiskFindingCard({ + finding, + onSelectPatternRange, +}: { + readonly finding: RegexRiskFinding; + readonly onSelectPatternRange?: (range: SourceRange) => void; +}) { + const content = ( + <> + + {finding.severity} + + + {finding.title} + + {finding.evidence.replaceAll("-", " ")} · {finding.confidence}{" "} + confidence · UTF-16 {finding.range.startUtf16}… + {finding.range.endUtf16} + + + + ); + return ( +
+ {onSelectPatternRange ? ( + + ) : ( +
{content}
+ )} +

{finding.explanation}

+
+
+
Example risk
+
{finding.exampleRisk}
+
+
+
Suggested investigation
+
{finding.suggestedInvestigation}
+
+
+
Limitations
+
{finding.limitations}
+
+
+
+ ); +} + +function TimeGrowthPlot({ result }: { readonly result: GrowthAnalysisResult }) { + const measured = result.samples.filter( + ( + sample, + ): sample is (typeof result.samples)[number] & { + readonly executionMs: number; + } => sample.executionMs !== undefined, + ); + const maximumTime = Math.max( + 0.01, + ...measured.map((sample) => sample.executionMs), + ); + const points = measured + .map((sample, index) => { + const x = + measured.length === 1 ? 50 : (index / (measured.length - 1)) * 100; + const y = 100 - (sample.executionMs / maximumTime) * 92; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + + return ( +
+
+ Execution time by generated input + + Maximum observed {formatMilliseconds(maximumTime)} · separate from + outcome status + +
+ {measured.length > 0 ? ( + + + + {points.split(" ").map((point) => { + const [x, y] = point.split(","); + return ( + + ); + })} + + ) : ( +

No completed timing sample.

+ )} +
+ ); +} + +function OutcomeGrowthPlot({ + result, +}: { + readonly result: GrowthAnalysisResult; +}) { + return ( +
+
+ Match and worker outcome by generated input + Timeout is distinct from non-match and crash +
+
+ {result.samples.map((sample) => ( + + ))} +
+ +
+ ); +} + +function GrowthSummary({ + result, + onSelectPatternRange, +}: { + readonly result: GrowthAnalysisResult; + readonly onSelectPatternRange?: (range: SourceRange) => void; +}) { + return ( +
+
+
+

+ Generated subject family · bounded observation +

+

Dynamic growth result

+
+ + {result.status.replaceAll("-", " ")} + +
+

{result.stoppedReason}

+
+ + +
+
+ + + + + + + + + + + + + {result.samples.map((sample) => ( + + + + + + + + + ))} + +
RepetitionsInput bytesTimeMatch resultStatusNormalized growth
{sample.repetitions.toLocaleString()}{sample.inputBytes.toLocaleString()}{formatMilliseconds(sample.executionMs)} + {sample.matched === undefined + ? "—" + : sample.matched + ? `${sample.matchCount?.toLocaleString() ?? "≥1"} match(es)` + : "no match"} + {sample.status.replaceAll("-", " ")} + {sample.normalizedGrowth === undefined + ? "—" + : `${sample.normalizedGrowth.toFixed(2)}×`} +
+
+ {result.dynamicFindings.map((finding) => ( + + ))} +

+ These observations apply only to this generated input family, browser + runtime and selected limits. They are not proof of general complexity or + safety. +

+
+ ); +} + +function numericValue( + value: string, + label: string, + minimum: number, + maximum: number, +): number { + const parsed = Number(value); + if ( + value.trim() === "" || + !Number.isFinite(parsed) || + parsed < minimum || + parsed > maximum + ) { + throw new RangeError(`${label} must be from ${minimum} to ${maximum}.`); + } + return parsed; +} + +export function AnalysisPanel({ + active, + flavour, + pattern, + flags, + subject, + replacement, + scanAll, + syntax, + onClose, + onSelectPatternRange, + createSupervisor = () => new AnalysisSupervisor(), +}: AnalysisPanelProps) { + const [view, setView] = useState("risk"); + const [activeRun, setActiveRun] = useState(); + const [progressState, setProgressState] = useState(); + const [message, setMessage] = useState( + "Analysis has not run for this configuration.", + ); + const [error, setError] = useState(); + const [benchmarkRecord, setBenchmarkRecord] = useState<{ + readonly configurationKey: symbol; + readonly result: RegexBenchmarkResult; + }>(); + const [growthRecord, setGrowthRecord] = useState<{ + readonly configurationKey: symbol; + readonly result: GrowthAnalysisResult; + }>(); + const [warmupIterations, setWarmupIterations] = useState( + String(DEFAULT_BENCHMARK_SETTINGS.warmupIterations), + ); + const [measuredIterations, setMeasuredIterations] = useState( + String(DEFAULT_BENCHMARK_SETTINGS.measuredIterations), + ); + const [benchmarkTimeoutMs, setBenchmarkTimeoutMs] = useState( + String(DEFAULT_BENCHMARK_SETTINGS.sampleTimeoutMs), + ); + const [growthPrefix, setGrowthPrefix] = useState( + DEFAULT_GROWTH_SETTINGS.prefix, + ); + const [growthFragment, setGrowthFragment] = useState( + DEFAULT_GROWTH_SETTINGS.repeatedFragment, + ); + const [growthSuffix, setGrowthSuffix] = useState( + DEFAULT_GROWTH_SETTINGS.suffix, + ); + const [growthStart, setGrowthStart] = useState( + String(DEFAULT_GROWTH_SETTINGS.startingRepetitions), + ); + const [growthMaximum, setGrowthMaximum] = useState( + String(DEFAULT_GROWTH_SETTINGS.maximumRepetitions), + ); + const [growthMultiplier, setGrowthMultiplier] = useState( + String(DEFAULT_GROWTH_SETTINGS.multiplier), + ); + const [growthSteps, setGrowthSteps] = useState( + String(DEFAULT_GROWTH_SETTINGS.maximumSteps), + ); + const [growthTimeoutMs, setGrowthTimeoutMs] = useState( + String(DEFAULT_GROWTH_SETTINGS.sampleTimeoutMs), + ); + const [growthMaximumBytes, setGrowthMaximumBytes] = useState( + String(DEFAULT_GROWTH_SETTINGS.maximumSubjectBytes), + ); + const [growthThreshold, setGrowthThreshold] = useState( + String(DEFAULT_GROWTH_SETTINGS.normalizedGrowthThreshold), + ); + const supervisor = useRef(undefined); + const abortController = useRef(undefined); + const generation = useRef(0); + + const syntaxCurrent = + syntax?.accepted === true && + syntax.root.raw === pattern && + syntax.root.support.flavour === flavour; + const analysisAvailable = flavour === "ecmascript" && syntaxCurrent; + const staticReport = useMemo( + () => + syntaxCurrent && syntax + ? analyseStaticRisk({ + flavour, + root: syntax.root, + flags, + scanAll, + replacement, + }) + : undefined, + [flavour, flags, replacement, scanAll, syntax, syntaxCurrent], + ); + const configurationKey = useMemo( + () => + Symbol( + `analysis-${flavour}-${flags.join("")}-${pattern.length}-${subject.length}-${replacement.length}-${String(scanAll)}`, + ), + [flavour, flags, pattern, replacement, scanAll, subject], + ); + const previousConfiguration = useRef(configurationKey); + const benchmarkResult = + benchmarkRecord?.configurationKey === configurationKey + ? benchmarkRecord.result + : undefined; + const growthResult = + growthRecord?.configurationKey === configurationKey + ? growthRecord.result + : undefined; + + const disposeActive = useCallback((abort = true) => { + if (abort) abortController.current?.abort(); + supervisor.current?.cancel(); + supervisor.current?.dispose(); + supervisor.current = undefined; + abortController.current = undefined; + }, []); + + useEffect(() => { + if (active) return; + const stoppedGeneration = generation.current + 1; + generation.current = stoppedGeneration; + disposeActive(); + globalThis.queueMicrotask(() => { + if (generation.current === stoppedGeneration) setActiveRun(undefined); + }); + }, [active, disposeActive]); + + useEffect(() => { + if (previousConfiguration.current === configurationKey) return; + previousConfiguration.current = configurationKey; + const stoppedGeneration = generation.current + 1; + generation.current = stoppedGeneration; + disposeActive(); + globalThis.queueMicrotask(() => { + if (generation.current !== stoppedGeneration) return; + setActiveRun(undefined); + setBenchmarkRecord(undefined); + setGrowthRecord(undefined); + setError(undefined); + setMessage( + "Configuration changed; previous timing results were cleared.", + ); + }); + }, [configurationKey, disposeActive]); + + useEffect( + () => () => { + generation.current += 1; + disposeActive(); + }, + [disposeActive], + ); + + const beginRun = useCallback( + (kind: ActiveRun) => { + disposeActive(); + const currentSupervisor = createSupervisor(); + const controller = new AbortController(); + supervisor.current = currentSupervisor; + abortController.current = controller; + generation.current += 1; + setActiveRun(kind); + setError(undefined); + setProgressState(undefined); + setMessage( + kind === "benchmark" + ? "Starting bounded benchmark…" + : "Starting bounded dynamic growth analysis…", + ); + return { + currentSupervisor, + controller, + runGeneration: generation.current, + }; + }, + [createSupervisor, disposeActive], + ); + + const finishRun = useCallback( + (runGeneration: number, currentSupervisor: AnalysisWorkerClient) => { + currentSupervisor.dispose(); + if (supervisor.current === currentSupervisor) { + supervisor.current = undefined; + abortController.current = undefined; + } + if (generation.current === runGeneration) { + setActiveRun(undefined); + setProgressState(undefined); + } + }, + [], + ); + + const runBenchmark = async () => { + if (!analysisAvailable) return; + const run = beginRun("benchmark"); + try { + const result = await runRegexBenchmark( + { + flavour: "ecmascript", + pattern, + flags, + subject, + replacement, + scanAll, + maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches, + settings: { + warmupIterations: numericValue( + warmupIterations, + "Warm-up iterations", + 0, + 100, + ), + measuredIterations: numericValue( + measuredIterations, + "Measured iterations", + 1, + 1_000, + ), + sampleTimeoutMs: numericValue( + benchmarkTimeoutMs, + "Sample timeout", + 25, + DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs, + ), + maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs, + }, + }, + run.currentSupervisor, + { + signal: run.controller.signal, + onProgress: (next) => { + if (generation.current !== run.runGeneration) return; + setProgressState(next); + setMessage(next.message); + }, + }, + ); + if (generation.current !== run.runGeneration) return; + setBenchmarkRecord({ configurationKey, result }); + setMessage( + result.status === "complete" + ? `Benchmark complete with ${result.completedMeasuredIterations.toLocaleString()} measured warm samples.` + : (result.stoppedReason ?? + `Benchmark stopped with status ${result.status}.`), + ); + } catch (cause) { + if (generation.current !== run.runGeneration) return; + const next = + cause instanceof Error ? cause.message : "Benchmark could not start."; + setError(next); + setMessage("Benchmark settings require review."); + } finally { + finishRun(run.runGeneration, run.currentSupervisor); + } + }; + + const growthSettings = (): GrowthSettings => ({ + prefix: growthPrefix, + repeatedFragment: growthFragment, + suffix: growthSuffix, + startingRepetitions: numericValue( + growthStart, + "Starting repetitions", + 1, + 10_000_000, + ), + maximumRepetitions: numericValue( + growthMaximum, + "Maximum repetitions", + 1, + 10_000_000, + ), + multiplier: numericValue(growthMultiplier, "Growth multiplier", 1.1, 10), + maximumSteps: numericValue(growthSteps, "Maximum steps", 1, 24), + sampleTimeoutMs: numericValue( + growthTimeoutMs, + "Sample timeout", + 25, + DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs, + ), + maximumWallTimeMs: DEFAULT_REGEX_LIMITS.maximumBenchmarkWallTimeMs, + maximumSubjectBytes: numericValue( + growthMaximumBytes, + "Maximum generated bytes", + 1, + DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes, + ), + normalizedGrowthThreshold: numericValue( + growthThreshold, + "Growth threshold", + 1.1, + 100, + ), + }); + + const runGrowth = async () => { + if (!analysisAvailable) return; + const run = beginRun("growth"); + try { + const result = await runGrowthAnalysis( + { + flavour: "ecmascript", + pattern, + flags, + scanAll, + maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches, + settings: growthSettings(), + }, + run.currentSupervisor, + { + signal: run.controller.signal, + onProgress: (next) => { + if (generation.current !== run.runGeneration) return; + setProgressState(next); + setMessage(next.message); + }, + }, + ); + if (generation.current !== run.runGeneration) return; + setGrowthRecord({ configurationKey, result }); + setMessage(result.stoppedReason); + } catch (cause) { + if (generation.current !== run.runGeneration) return; + const next = + cause instanceof Error + ? cause.message + : "Growth analysis could not start."; + setError(next); + setMessage("Growth settings require review."); + } finally { + finishRun(run.runGeneration, run.currentSupervisor); + } + }; + + const cancel = () => { + abortController.current?.abort(); + supervisor.current?.cancel(); + setMessage( + activeRun === "benchmark" + ? "Cancelling benchmark and terminating its worker…" + : "Cancelling growth analysis and terminating its worker…", + ); + }; + + const progressMaximum = Math.max(1, progressState?.total ?? 1); + const progressValue = Math.min( + progressMaximum, + progressState?.completed ?? 0, + ); + + return ( +
+
+
+

Advisory structure + bounded observations

+

Performance & risk analysis

+
+
+ ECMAScript-specific + {onClose ? ( + + ) : null} +
+
+ +
+ + +
+ + {flavour !== "ecmascript" ? ( +

+ The current analyser supports ECMAScript 2025 only. It will not apply + ECMAScript heuristics or native-browser timing claims to {flavour}. +

+ ) : !syntaxCurrent ? ( +

+ Analysis requires a current pattern accepted by the ECMAScript syntax + provider. +

+ ) : null} + +
+ {activeRun ? "running" : "ready"} + {message} +
+ {progressState ? ( +
+ + + {progressState.completed.toLocaleString()} /{" "} + {progressState.total.toLocaleString()} + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + {view === "risk" ? ( +
+
+
+
+

Static · normalized ECMAScript AST

+

Potential risk findings

+
+ {staticReport ? ( + + {staticReport.analysedNodes.toLocaleString()} nodes reviewed + + ) : null} +
+ {staticReport ? ( + <> +

{staticReport.summary}

+
+ {staticReport.findings.map((finding) => ( + + ))} +
+
+ Static analyser scope and limitations +
    + {staticReport.limitations.map((limitation) => ( +
  • {limitation}
  • + ))} +
+
+ + ) : ( +

+ Static findings are unavailable until the active pattern has a + current supported syntax tree. +

+ )} +
+ +
+
+
+

+ Dynamic · killable native-engine worker +

+

Bounded growth probe

+
+ 30 s aggregate hard stop +
+

+ Subjects are built as prefix + repeated fragment + suffix. Use a + representative near-miss; generation stops on timeout, selected + growth threshold, byte bound, repetition bound, step bound or + cancellation. +

+
+ + + +
+
+ + + + + + + +
+
+ + {activeRun === "growth" ? ( + + ) : null} +
+ {growthResult ? ( + + ) : null} +
+
+ ) : ( +
+
+
+
+

+ Cold first use + warm measured samples +

+

Bounded benchmark

+
+ + {utf8ByteLength(subject).toLocaleString()} subject bytes + +
+

+ Each timing sample measures compile, first match, all matches and + replacement separately in the native ECMAScript worker. Worker + startup is reported only as cold start. Trace mode is never used. +

+
+ + + +
+
+
+
Pattern
+
+ {pattern.length.toLocaleString()} UTF-16 units · flags{" "} + {flags.join("") || "(none)"} +
+
+
+
Subject
+
+ {subject.length.toLocaleString()} UTF-16 units ·{" "} + {utf8ByteLength(subject).toLocaleString()} bytes +
+
+
+
Replacement
+
+ {replacement.length.toLocaleString()} UTF-16 units · output + estimate is bounded before timing +
+
+
+
+ + {activeRun === "benchmark" ? ( + + ) : null} +
+ {benchmarkResult ? ( + + ) : null} +
+
+ )} +
+ ); +} diff --git a/src/components/CapabilityPanel.test.tsx b/src/components/CapabilityPanel.test.tsx index 431bf72..eca41a8 100644 --- a/src/components/CapabilityPanel.test.tsx +++ b/src/components/CapabilityPanel.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; import type { RegexExecutionResult } from "../regex/model/match"; import type { RegexSyntaxResult } from "../regex/model/syntax"; import { CapabilityPanel } from "./CapabilityPanel"; @@ -78,11 +79,66 @@ describe("CapabilityPanel", () => { ).toBeInTheDocument(); expect(screen.getByText("Fixture engine")).toBeInTheDocument(); expect(screen.getByText("123")).toBeInTheDocument(); + expect( + screen.getByText("Not separately reported by this adapter"), + ).toBeInTheDocument(); + expect(screen.getByText("1", { selector: "dd" })).toBeInTheDocument(); expect(screen.getByText("UTF-8 bytes")).toBeInTheDocument(); + expect(screen.getByText("fixture")).toBeInTheDocument(); const replacement = screen.getByText("Replacement").closest("div"); expect(replacement).toHaveTextContent("Unavailable"); const compilation = screen.getByText("Compilation").closest("div"); expect(compilation).toHaveTextContent("Available"); + const generatedCases = screen.getByText("Generated cases").closest("div"); + expect(generatedCases).toHaveTextContent( + "deterministic AST candidates, retained only after actual-engine verification", + ); + const formatting = screen.getByText("Pattern formatting").closest("div"); + expect(formatting).toHaveTextContent( + "grammar-backed literal/control escaping with mandatory exact-snapshot validation", + ); + }); + + it("does not advertise ECMAScript generation for the partial PCRE2 syntax provider", () => { + render( + , + ); + + 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( + , + ); + + await user.click( + screen.getByRole("button", { name: "Close capabilities" }), + ); + expect(onClose).toHaveBeenCalledOnce(); }); }); diff --git a/src/components/CapabilityPanel.tsx b/src/components/CapabilityPanel.tsx index 2a4e8eb..fe527be 100644 --- a/src/components/CapabilityPanel.tsx +++ b/src/components/CapabilityPanel.tsx @@ -1,4 +1,4 @@ -import { REGEXPP_VERSION, SYNTAX_PROFILE } from "../version"; +import { SYNTAX_PROFILE } from "../version"; import type { RegexExecutionResult } from "../regex/model/match"; import type { RegexSyntaxResult } from "../regex/model/syntax"; import type { RegexEngineCapabilities } from "../regex/model/flavour"; @@ -35,23 +35,27 @@ function offsetLabel( export function CapabilityPanel({ syntax, execution, + onClose, }: { readonly syntax?: RegexSyntaxResult; readonly execution?: RegexExecutionResult; + readonly onClose?: () => void; }) { const capabilities = execution?.engine.capabilities; + const activeFlavour = + execution?.engine.flavour ?? syntax?.root.support.flavour; const rows = [ [ "Flavour", execution?.engine.flavour ?? syntax?.root.support.flavour ?? - "ECMAScript (awaiting workers)", + "Awaiting flavour workers", ], [ "Syntax provider", syntax ? `${syntax.provider.id} ${syntax.provider.version}` - : `regexpp ${REGEXPP_VERSION} (awaiting syntax worker)`, + : "Awaiting syntax worker result", ], ["Syntax profile", SYNTAX_PROFILE], [ @@ -65,9 +69,20 @@ export function CapabilityPanel({ execution?.engine.engineName ?? "Awaiting first engine result", ], [ - "Engine/runtime version", + "Engine version", execution?.engine.engineVersion ?? "Shown after first execution", ], + [ + "Runtime version", + execution?.engine.runtimeVersion ?? + (execution + ? "Not separately reported by this adapter" + : "Shown after first execution"), + ], + [ + "Adapter version", + execution?.engine.adapterVersion ?? "Shown after first execution", + ], ["Native offsets", offsetLabel(execution?.engine.offsetUnit)], ["Compilation", capability(capabilities, "compilation")], ["Matching", capability(capabilities, "matching")], @@ -90,6 +105,38 @@ export function CapabilityPanel({ ), ], ["Benchmark", capability(capabilities, "benchmark")], + [ + "Static risk analysis", + activeFlavour === "ecmascript" + ? "Available — advisory ECMAScript 2025 heuristics" + : activeFlavour === "pcre2" + ? "Unavailable — ECMAScript heuristics are not applied to PCRE2" + : "Awaiting flavour worker result", + ], + [ + "Generated cases", + activeFlavour === "ecmascript" + ? "Available — deterministic AST candidates, retained only after actual-engine verification" + : activeFlavour === "pcre2" + ? "Unavailable — the partial PCRE2 provider does not expose a complete generation AST" + : "Awaiting flavour worker result", + ], + [ + "Pattern formatting", + activeFlavour === "ecmascript" + ? "Available — grammar-backed literal/control escaping with mandatory exact-snapshot validation" + : activeFlavour === "pcre2" + ? "Unavailable — no complete PCRE2 grammar-backed formatter is implemented" + : "Awaiting flavour worker result", + ], + [ + "Known syntax gaps", + syntax + ? syntax.coverage.unsupportedConstructs.length > 0 + ? syntax.coverage.unsupportedConstructs.join("; ") + : "None reported by the active provider" + : "Awaiting syntax worker result", + ], ] as const; return (
Current provider and adapter metadata

Capabilities

- Community build +
+ Community build + {onClose ? ( + + ) : null} +
{rows.map(([term, value]) => ( @@ -112,10 +172,11 @@ export function CapabilityPanel({ ))}

- Next flavour: official PCRE2 10.47 WebAssembly with actual callout - traces. Python, Go, Rust, .NET and Java remain unavailable until their - named runtimes pass the same worker, offset, conformance and licensing - gates. + PCRE2 10.47 matching and substitution run in the bundled WebAssembly + worker. Its separate trace worker exposes bounded reported automatic + callouts; movement classifications remain explicitly derived. Python, + Go, Rust, .NET and Java remain unavailable until their named runtimes + pass the same worker, offset, conformance and licensing gates.

); diff --git a/src/components/ComparisonCodePanel.css b/src/components/ComparisonCodePanel.css new file mode 100644 index 0000000..7311987 --- /dev/null +++ b/src/components/ComparisonCodePanel.css @@ -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)); + } +} diff --git a/src/components/ComparisonCodePanel.test.tsx b/src/components/ComparisonCodePanel.test.tsx new file mode 100644 index 0000000..4750f33 --- /dev/null +++ b/src/components/ComparisonCodePanel.test.tsx @@ -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( + \\p{Letter}+)"} + subject="Grüße" + replacement="$!" + 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("(?\\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("(?\\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: "(?\\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("(?\\w++)"); + }); + + it("closes through the dialog action", () => { + const { onClose } = renderPanel(); + fireEvent.click( + screen.getByRole("button", { name: "Close compare and generate" }), + ); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/ComparisonCodePanel.tsx b/src/components/ComparisonCodePanel.tsx new file mode 100644 index 0000000..c9c7295 --- /dev/null +++ b/src/components/ComparisonCodePanel.tsx @@ -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 ( +
+
+
+

Exact request {side.requestIdentity}

+

+ {side.input.flavour === "ecmascript" ? "ECMAScript" : "PCRE2"} +

+
+ + {side.runtime.status} + +
+
+
+
Pattern syntax
+
+ {side.syntax.pattern + ? side.syntax.pattern.accepted + ? "Accepted" + : "Rejected" + : side.syntax.status} +
+
+
+
Provider
+
+ {side.syntax.pattern + ? `${side.syntax.pattern.provider.id} ${side.syntax.pattern.provider.version}` + : "Unavailable"} +
+
+
+
Engine compile
+
+ {execution + ? execution.accepted + ? "Accepted" + : "Rejected" + : side.runtime.status} +
+
+
+
Engine identity
+
+ {execution + ? `${execution.engine.engineName} · ${execution.engine.engineVersion}` + : "Unavailable"} +
+
+
+
Exact flags
+
+ user {side.input.flags.join("") || "none"} · effective{" "} + {execution?.flags.effectiveFlags || "unavailable"} +
+
+
+
Options
+
+ {JSON.stringify(side.input.options)} +
+
+
+
Matches
+
{execution?.matches.length.toLocaleString() ?? "Unavailable"}
+
+
+
Offsets
+
+ native {execution?.engine.offsetUnit ?? "unavailable"} · comparison + UTF-16 +
+
+ {side.runtime.replacement ? ( +
+
Replacement
+
+ {side.runtime.replacement.outputBytes.toLocaleString()} bytes + {side.runtime.replacement.truncated ? " · incomplete" : ""} +
+
+ ) : null} +
+ {side.syntax.pattern?.coverage.unsupportedConstructs.length ? ( +
+ Provider coverage gaps +
    + {side.syntax.pattern.coverage.unsupportedConstructs.map((gap) => ( +
  • {gap}
  • + ))} +
+
+ ) : null} + {diagnostics.length > 0 ? ( +
+ {diagnostics.length.toLocaleString()} diagnostic(s) +
    + {diagnostics.slice(0, 100).map((diagnostic) => ( +
  • + {diagnostic.severity}: {diagnostic.message} +
  • + ))} +
+
+ ) : null} + {side.syntax.error || side.runtime.error ? ( +

+ {side.syntax.error ?? side.runtime.error} +

+ ) : null} +
+ ); +} + +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 ( +
+
+

Bounded comparison verdict

+

{result.status.replaceAll("-", " ")}

+

{result.notices[0]?.message}

+ + {result.elapsedMs.toFixed(1)} ms wall time ·{" "} + {result.totalDifferences.toLocaleString()} recorded difference(s) + +
+ {result.notComparable.length > 0 ? ( +
+

Why this run is not comparable

+
    + {result.notComparable.map((reason, index) => ( +
  • + {reason.code.replaceAll("-", " ")}:{" "} + {reason.message} +
  • + ))} +
+
+ ) : null} +
+ + +
+
+

Syntax, execution and replacement differences

+ {visibleDifferences.length === 0 ? ( +

+ No retained semantic differences for this subject. Engine metadata + can still differ. +

+ ) : ( + + + + + + + + + + + {visibleDifferences.map((difference, index) => ( + + + + + + + ))} + +
KindFindingECMAScriptPCRE2
{difference.kind.replaceAll("-", " ")}{difference.summary} + {preview(difference.left)} + + {preview(difference.right)} +
+ )} + {visibleDifferences.length < result.totalDifferences ? ( +

+ Rendering the first {visibleDifferences.length.toLocaleString()} of{" "} + {result.totalDifferences.toLocaleString()} differences. +

+ ) : null} +
+ {visibleAlignments.length > 0 ? ( +
+

Match alignment by normalized editor range

+ + + + + + + + + + + {visibleAlignments.map((alignment, index) => ( + + + + + + + ))} + +
AlignmentECMAScript UTF-16PCRE2 UTF-16Equal
{alignment.alignment.replaceAll("-", " ")} + {alignment.left + ? `${alignment.left.range.startUtf16}–${alignment.left.range.endUtf16}` + : "—"} + + {alignment.right + ? `${alignment.right.range.startUtf16}–${alignment.right.range.endUtf16}` + : "—"} + {alignment.equal ? "yes" : "no"}
+ {visibleAlignments.length < result.totalMatchAlignments ? ( +

+ Rendering the first {visibleAlignments.length.toLocaleString()} of{" "} + {result.totalMatchAlignments.toLocaleString()} alignments. +

+ ) : null} +
+ ) : null} +
+ ); +} + +export function ComparisonCodePanel({ + activeFlavour, + activeFlags, + activeOptions, + pattern: initialPattern, + subject: initialSubject, + replacement: initialReplacement, + scanAll: initialScanAll, + timeoutMs: initialTimeoutMs, + onClose, +}: ComparisonCodePanelProps) { + const [tab, setTab] = useState("compare"); + const [patternModel, setPatternModel] = + useState("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({ + kind: "idle", + message: "Configure two exact engine requests.", + }); + const [result, setResult] = useState(); + const [generated, setGenerated] = useState(); + const [generatedStatus, setGeneratedStatus] = useState(""); + const orchestrator = useRef(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 ( +
+
+
+

Evidence, not automatic translation

+

Compare & generate

+
+ +
+
+ + +
+
+
+ + + +
+ {patternModel === "shared" ? ( +