fix: retain completed results during live editing

This commit is contained in:
2026-07-27 13:05:02 +02:00
parent 7f3a91ad37
commit 2797250562
22 changed files with 576 additions and 117 deletions

View File

@@ -2,6 +2,16 @@
## Unreleased ## Unreleased
## 0.4.1 — 2026-07-27
- Keep the last completed syntax and execution frame visible while edited
inputs are parsed and executed, then replace it atomically with the newest
revision instead of rendering transient empty panels.
- Bind every retained result to its exact flavour, options, pattern, flags,
subject, replacement, mode and syntax snapshots. Mark it as stale, disable
execution until current syntax is ready, and suppress stale editor ranges
and cross-panel selection.
## 0.4.0 — 2026-07-27 ## 0.4.0 — 2026-07-27
- Expand the workbench from four to twelve selectable execution profiles: - Expand the workbench from four to twelve selectable execution profiles:

View File

@@ -8,9 +8,9 @@ Patterns, replacement templates, test text and project files stay in the
browser. There is no account, backend, upload, remote execution, telemetry, browser. There is no account, backend, upload, remote execution, telemetry,
runtime CDN, remote font, or cloud-save service. runtime CDN, remote font, or cloud-save service.
## Current release — 0.4.0 ## Current release — 0.4.1
The v0.4.0 release contains twelve selectable, executable profiles: The v0.4.1 release contains twelve selectable, executable profiles:
- **JavaScript (ECMAScript)** — the current browser's native `RegExp`, with - **JavaScript (ECMAScript)** — the current browser's native `RegExp`, with
ECMAScript 2025 syntax supplied by regexpp 4.12.2; ECMAScript 2025 syntax supplied by regexpp 4.12.2;
@@ -99,6 +99,9 @@ relabeled Scala engine.
- Parsing and execution run in separate workers. - Parsing and execution run in separate workers.
- Actual execution can be terminated by killing its worker. - Actual execution can be terminated by killing its worker.
- The worker is recreated after timeout, crash or cancellation. - The worker is recreated after timeout, crash or cancellation.
- Live editing retains the last completed, input-bound syntax and result frame
until the newest revision finishes. Stale frames are labelled and cannot
enable Run, decorate current editors, or drive current-input selections.
- ECMAScript capture ranges use the engine's `d` indices result; every other - ECMAScript capture ranges use the engine's `d` indices result; every other
profile returns its native range records through a bounded fixed bridge. profile returns its native range records through a bounded fixed bridge.
- Named, numbered, optional, empty and repeated-final captures are represented. - Named, numbered, optional, empty and repeated-final captures are represented.

View File

@@ -1,10 +1,10 @@
# Source identity # Source identity
- Project: Regex Tools - Project: Regex Tools
- Release version: `0.4.0` - Release version: `0.4.1`
- Release tag: `v0.4.0` - Release tag: `v0.4.1`
- Repository: <https://git.add-ideas.de/zemion/regex-tools> - Repository: <https://git.add-ideas.de/zemion/regex-tools>
- Previous release tag: `v0.3.0` - Previous release tag: `v0.4.0`
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Copyright: © 2026 Albrecht Degering - Copyright: © 2026 Albrecht Degering
@@ -82,7 +82,7 @@ metadata, closed file sets, checksums and exact licence/notice material are
installed under `public/engines/` and included in the application source and installed under `public/engines/` and included in the application source and
build. build.
The v0.4.0 release includes native browser ECMAScript, standalone PCRE2 10.47, The v0.4.1 release includes native browser ECMAScript, standalone PCRE2 10.47,
PHP 8.5.8 `preg` with PCRE2 10.44, legacy Perl 5.28.1 via WebPerl 0.09-beta, PHP 8.5.8 `preg` with PCRE2 10.44, legacy Perl 5.28.1 via WebPerl 0.09-beta,
CPython 3.14.2 `re`, CRuby 4.0.0 `Regexp`, TeaVM 0.15.0 `java.util.regex`, CPython 3.14.2 `re`, CRuby 4.0.0 `Regexp`, TeaVM 0.15.0 `java.util.regex`,
Emscripten 6.0.4 libc++ `std::wregex`, Go 1.26.5 `regexp`, Rust `regex` 1.13.1, Emscripten 6.0.4 libc++ `std::wregex`, Go 1.26.5 `regexp`, Rust `regex` 1.13.1,
@@ -91,4 +91,4 @@ TeaVM-backed Scala/JVM compatibility profile.
Earlier releases remain available under their historical tags and artifact Earlier releases remain available under their historical tags and artifact
coordinates. A future public artifact must bump package, manifest, tag and coordinates. A future public artifact must bump package, manifest, tag and
source identity together; it must not reuse `v0.4.0`. source identity together; it must not reuse `v0.4.1`.

View File

@@ -18,7 +18,7 @@ budget. Consumers must inspect the preview status rather than treating a
preview as complete. In particular, list export is disabled if its template preview as complete. In particular, list export is disabled if its template
would consume an incomplete value. would consume an incomplete value.
Repeated captures expose only the final retained value in all twelve v0.4.0 Repeated captures expose only the final retained value in all twelve v0.4.1
profiles. This includes .NET even though its native API can retain capture profiles. This includes .NET even though its native API can retain capture
history: the current adapter deliberately normalizes only the final record and history: the current adapter deliberately normalizes only the final record and
reports `captureHistory: false`. The UI labels the limitation and does not reports `captureHistory: false`. The UI labels the limitation and does not

View File

@@ -1,6 +1,6 @@
# Flavour support # Flavour support
Version 0.4.0 exposes twelve executable profiles. “Partial lexical provider” Version 0.4.1 exposes twelve executable profiles. “Partial lexical provider”
means that Regex Tools recognizes common structure and replacement tokens for means that Regex Tools recognizes common structure and replacement tokens for
explanation only; the named runtime compiler and replacement operation remain explanation only; the named runtime compiler and replacement operation remain
authoritative. authoritative.

View File

@@ -11,7 +11,7 @@ selected real ECMAScript or PCRE2 worker. It can preserve:
authoritative, untruncated output. authoritative, untruncated output.
PHP, Perl, Python, Ruby, Java, C++, Go, Rust, .NET and Scala compatibility are PHP, Perl, Python, Ruby, Java, C++, Go, Rust, .NET and Scala compatibility are
not supported minimization targets in v0.4.0. The workspace disables reduction not supported minimization targets in v0.4.1. The workspace disables reduction
for those active profiles; it does not substitute ECMAScript, PCRE2 or a for those active profiles; it does not substitute ECMAScript, PCRE2 or a
lexical provider as an execution oracle. lexical provider as an execution oracle.

View File

@@ -38,9 +38,9 @@ should test both a direct root mount and a nested mount such as
`/apps/regex/`, including cold loading of all twelve profiles. `/apps/regex/`, including cold loading of all twelve profiles.
The Portal must consume the immutable release ZIP, verify its SHA-256, verify The Portal must consume the immutable release ZIP, verify its SHA-256, verify
manifest ID `de.add-ideas.regex-tools` and version `0.4.0`, and mount it at a manifest ID `de.add-ideas.regex-tools` and version `0.4.1`, and mount it at a
relative target such as `apps/regex/`. relative target such as `apps/regex/`.
The package, manifest, tag, artifact URL and checksum must all use the v0.4.0 The package, manifest, tag, artifact URL and checksum must all use the v0.4.1
identity. Historical artifact coordinates remain immutable and must not be identity. Historical artifact coordinates remain immutable and must not be
overwritten or reused. overwritten or reused.

View File

@@ -4,7 +4,7 @@ Inspection date: 2026-07-27.
| Reference | Exact revision/version | Licence | Use and copying status | | Reference | Exact revision/version | Licence | Use and copying status |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Regex Tools | Release identity `v0.4.0` | GPL-3.0-or-later | Application source written for this project. | | Regex Tools | Release identity `v0.4.1` | GPL-3.0-or-later | Application source written for this project. |
| Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. | | Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. |
| Toolbox Portal | `9347554324299c8ec9dfaec2c9730907736f62c3` / aggregate v0.8.0 / package 0.2.7 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. | | Toolbox Portal | `9347554324299c8ec9dfaec2c9730907736f62c3` / aggregate v0.8.0 / package 0.2.7 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. |
| regexpp | 4.12.2; pinned npm integrity in `package-lock.json` | MIT | ECMAScript 2025 syntax provider; normalized in the syntax worker. | | regexpp | 4.12.2; pinned npm integrity in `package-lock.json` | MIT | ECMAScript 2025 syntax provider; normalized in the syntax worker. |
@@ -30,6 +30,6 @@ Inspection date: 2026-07-27.
| RegexLib | Website inspected | Redistribution licence not established | No fixture copied. | | RegexLib | Website inspected | Redistribution licence not established | No fixture copied. |
| RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. | | RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. |
All v0.1.0 through v0.4.0 conformance cases are project-authored. See All v0.1.0 through v0.4.1 conformance cases are project-authored. See
`tests/fixtures/README.md`. Runtime pack metadata and adjacent licence/notice `tests/fixtures/README.md`. Runtime pack metadata and adjacent licence/notice
files are authoritative for the exact binary distribution. files are authoritative for the exact binary distribution.

View File

@@ -18,8 +18,8 @@ on publication failure, replaces an existing exact version only with
`--force`, and produces: `--force`, and produces:
```text ```text
release/regex-tools-0.4.0.zip release/regex-tools-0.4.1.zip
release/regex-tools-0.4.0.zip.sha256 release/regex-tools-0.4.1.zip.sha256
``` ```
The release scan checks ASCII/UTF-8, UTF-16LE and UTF-16BE content in text, The release scan checks ASCII/UTF-8, UTF-16LE and UTF-16BE content in text,
@@ -43,6 +43,5 @@ anchored base-runtime legal files. The C++ gate requires its exact traced
component/source manifest and six independently pinned legal files. The release component/source manifest and six independently pinned legal files. The release
packager names every one of those files explicitly. packager names every one of those files explicitly.
The existing v0.1.0, v0.2.0 and v0.3.0 ZIPs and matching sidecars are The existing v0.1.0 through v0.4.0 ZIPs and matching sidecars are historical
historical immutable artifacts. Creating v0.4.0 must not replace, rename or immutable artifacts. Creating v0.4.1 must not replace, rename or remove them.
remove them.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "regex-tools", "name": "regex-tools",
"version": "0.4.0", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "regex-tools", "name": "regex-tools",
"version": "0.4.0", "version": "0.4.1",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.2", "@add-ideas/toolbox-contract": "0.2.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "regex-tools", "name": "regex-tools",
"version": "0.4.0", "version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.", "description": "Develop, explain, test and apply regular expressions locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",

View File

@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.regex-tools", "id": "de.add-ideas.regex-tools",
"name": "Regex Tools", "name": "Regex Tools",
"version": "0.4.0", "version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.", "description": "Develop, explain, test and apply regular expressions locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",

View File

@@ -19,7 +19,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0); const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const gplVersion3TextSha256 = const gplVersion3TextSha256 =
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0"; "fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
const expectedApplicationVersion = "0.4.0"; const expectedApplicationVersion = "0.4.1";
const requiredRootFiles = [ const requiredRootFiles = [
"LICENSE", "LICENSE",
"README.md", "README.md",

View File

@@ -8,6 +8,7 @@ export interface ExplanationTreeProps {
readonly root?: NormalizedRegexNode; readonly root?: NormalizedRegexNode;
readonly selectedId?: string; readonly selectedId?: string;
readonly flags?: readonly string[]; readonly flags?: readonly string[];
readonly stale?: boolean;
readonly onSelect: (node: NormalizedRegexNode) => void; readonly onSelect: (node: NormalizedRegexNode) => void;
} }
@@ -165,6 +166,7 @@ export function ExplanationTree({
root, root,
selectedId, selectedId,
flags = [], flags = [],
stale = false,
onSelect, onSelect,
}: ExplanationTreeProps) { }: ExplanationTreeProps) {
const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]); const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]);
@@ -191,8 +193,18 @@ export function ExplanationTree({
<p className="eyebrow">Pattern structure</p> <p className="eyebrow">Pattern structure</p>
<h2 id="explanation-heading">Explanation tree</h2> <h2 id="explanation-heading">Explanation tree</h2>
</div> </div>
<span className="provenance-badge"> <span
Deterministic · syntax provider className={`provenance-badge ${stale ? "is-stale" : ""}`}
data-testid={stale ? "stale-pattern-syntax" : undefined}
title={
stale
? "This is the last parsed pattern. Its ranges are not applied to the current editor."
: undefined
}
>
{stale
? "Stale snapshot · checking current pattern"
: "Deterministic · syntax provider"}
</span> </span>
</header> </header>
{totalNodes > renderedNodes ? ( {totalNodes > renderedNodes ? (

View File

@@ -74,6 +74,24 @@ describe("ReplacementPanel completeness", () => {
}, },
); );
it("labels retained tokens with the flavour that parsed the snapshot", () => {
render(
<ReplacementPanel
flavour="python"
presentedFlavour="java"
replacement="$1"
onReplacementChange={() => undefined}
onSelectToken={() => undefined}
onSelectContribution={() => undefined}
/>,
);
expect(screen.getByText("Python re template syntax")).toBeInTheDocument();
expect(
screen.getByText("Java Pattern derived token explanation"),
).toBeInTheDocument();
});
it("labels a match-limited replacement as partial", () => { it("labels a match-limited replacement as partial", () => {
renderResult({ renderResult({
execution: { ...execution, truncated: true }, execution: { ...execution, truncated: true },

View File

@@ -71,7 +71,11 @@ export function ReplacementPanel({
replacement, replacement,
onReplacementChange, onReplacementChange,
syntax, syntax,
presentedSyntax,
syntaxStale = false,
presentedFlavour,
result, result,
resultFlavour,
preview, preview,
selectedToken, selectedToken,
selectedContributionId, selectedContributionId,
@@ -83,7 +87,11 @@ export function ReplacementPanel({
readonly replacement: string; readonly replacement: string;
readonly onReplacementChange: (value: string) => void; readonly onReplacementChange: (value: string) => void;
readonly syntax?: ReplacementSyntaxResult; readonly syntax?: ReplacementSyntaxResult;
readonly presentedSyntax?: ReplacementSyntaxResult;
readonly syntaxStale?: boolean;
readonly presentedFlavour?: RegexFlavourId;
readonly result?: RegexReplacementResult; readonly result?: RegexReplacementResult;
readonly resultFlavour?: RegexFlavourId;
readonly preview?: ReplacementPreview; readonly preview?: ReplacementPreview;
readonly selectedToken?: ReplacementToken; readonly selectedToken?: ReplacementToken;
readonly selectedContributionId?: string; readonly selectedContributionId?: string;
@@ -96,8 +104,16 @@ export function ReplacementPanel({
const activeFlavour = const activeFlavour =
flavour ?? result?.execution.engine.flavour ?? "ecmascript"; flavour ?? result?.execution.engine.flavour ?? "ecmascript";
const engineLabel = TEMPLATE_FLAVOUR_LABELS[activeFlavour]; const engineLabel = TEMPLATE_FLAVOUR_LABELS[activeFlavour];
const retainedTokens = syntax?.tokens ?? []; const presentedEngineLabel =
const editorTokens = retainedTokens.slice( TEMPLATE_FLAVOUR_LABELS[presentedFlavour ?? activeFlavour];
const resultEngineLabel =
TEMPLATE_FLAVOUR_LABELS[
resultFlavour ?? result?.execution.engine.flavour ?? activeFlavour
];
const retainedSyntax = presentedSyntax ?? syntax;
const retainedTokens = retainedSyntax?.tokens ?? [];
const currentTokens = syntax?.tokens ?? [];
const editorTokens = currentTokens.slice(
0, 0,
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks, TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks,
); );
@@ -129,7 +145,7 @@ export function ReplacementPanel({
maximumLengthUtf16={ maximumLengthUtf16={
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
} }
selectedRange={selectedToken?.range} selectedRange={syntaxStale ? undefined : selectedToken?.range}
marks={[ marks={[
...editorTokens.map((token) => ({ ...editorTokens.map((token) => ({
id: token.id, id: token.id,
@@ -137,7 +153,7 @@ export function ReplacementPanel({
className: `cm-replacement-token cm-replacement-${token.kind}`, className: `cm-replacement-token cm-replacement-${token.kind}`,
label: token.explanation, label: token.explanation,
})), })),
...(selectedToken ...(!syntaxStale && selectedToken
? [ ? [
{ {
id: "selected-replacement-token", id: "selected-replacement-token",
@@ -163,7 +179,7 @@ export function ReplacementPanel({
unit limit. unit limit.
</p> </p>
) : null} ) : null}
{retainedTokens.length > {currentTokens.length >
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks ? ( TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks ? (
<p <p
className="render-limit-banner" className="render-limit-banner"
@@ -171,7 +187,7 @@ export function ReplacementPanel({
> >
<strong>Editor decorations limited.</strong> Showing the first{" "} <strong>Editor decorations limited.</strong> Showing the first{" "}
{TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks.toLocaleString()}{" "} {TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks.toLocaleString()}{" "}
of {retainedTokens.length.toLocaleString()} complete replacement of {currentTokens.length.toLocaleString()} complete replacement
tokens. tokens.
</p> </p>
) : null} ) : null}
@@ -182,9 +198,24 @@ export function ReplacementPanel({
> >
<header className="panel-heading"> <header className="panel-heading">
<div> <div>
<p className="eyebrow">Derived token explanation</p> <p className="eyebrow">
{presentedEngineLabel} derived token explanation
</p>
<h2 id="replacement-tree-heading">Replacement tokens</h2> <h2 id="replacement-tree-heading">Replacement tokens</h2>
</div> </div>
<span
className={`provenance-badge ${syntaxStale ? "is-stale" : ""}`}
data-testid={syntaxStale ? "stale-replacement-syntax" : undefined}
title={
syntaxStale
? "These are the last parsed template tokens. Their ranges are not applied to the current editor."
: undefined
}
>
{syntaxStale
? "Stale snapshot · checking current template"
: "Current template"}
</span>
</header> </header>
{renderedTokens.length > 0 ? ( {renderedTokens.length > 0 ? (
<> <>
@@ -222,12 +253,12 @@ export function ReplacementPanel({
</> </>
) : ( ) : (
<p className="empty-state"> <p className="empty-state">
{syntax?.accepted === false {retainedSyntax?.accepted === false
? "Token parsing is unavailable until the template is within the input limit." ? "Token parsing is unavailable until the template is within the input limit."
: "Enter a replacement template."} : "Enter a replacement template."}
</p> </p>
)} )}
{syntax?.diagnosticsTruncated ? ( {retainedSyntax?.diagnosticsTruncated ? (
<p <p
className="render-limit-banner" className="render-limit-banner"
data-testid="replacement-diagnostic-render-limit" data-testid="replacement-diagnostic-render-limit"
@@ -237,7 +268,8 @@ export function ReplacementPanel({
{TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics.toLocaleString()}{" "} {TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics.toLocaleString()}{" "}
entries, including a summary, for{" "} entries, including a summary, for{" "}
{( {(
syntax.totalDiagnostics ?? syntax.diagnostics.length retainedSyntax.totalDiagnostics ??
retainedSyntax.diagnostics.length
).toLocaleString()}{" "} ).toLocaleString()}{" "}
findings. findings.
</p> </p>
@@ -252,7 +284,7 @@ export function ReplacementPanel({
<header className="panel-heading"> <header className="panel-heading">
<div> <div>
<p className="eyebrow"> <p className="eyebrow">
Native matches · bounded {engineLabel} substitution Native matches · bounded {resultEngineLabel} substitution
</p> </p>
<h2>Replacement output</h2> <h2>Replacement output</h2>
</div> </div>

View File

@@ -37,6 +37,7 @@ const syntaxHarness = vi.hoisted(() => ({
const engineHarness = vi.hoisted(() => ({ const engineHarness = vi.hoisted(() => ({
requests: [] as RegexExecutionRequest[], requests: [] as RegexExecutionRequest[],
emitWholeFirstCharacterMatch: false,
})); }));
vi.mock("../editors/CodeEditor", () => ({ vi.mock("../editors/CodeEditor", () => ({
@@ -45,18 +46,35 @@ vi.mock("../editors/CodeEditor", () => ({
onChange, onChange,
label, label,
readOnly, readOnly,
marks,
selectedRange,
onSelectionChange,
}: { }: {
readonly value: string; readonly value: string;
readonly onChange?: (value: string) => void; readonly onChange?: (value: string) => void;
readonly label: string; readonly label: string;
readonly readOnly?: boolean; readonly readOnly?: boolean;
readonly marks?: readonly unknown[];
readonly selectedRange?: { readonly startUtf16: number };
readonly onSelectionChange?: (range: {
readonly startUtf16: number;
readonly endUtf16: number;
}) => void;
}) { }) {
return ( return (
<textarea <textarea
aria-label={label} aria-label={label}
value={value} value={value}
readOnly={readOnly} readOnly={readOnly}
data-mark-count={marks?.length ?? 0}
data-selected-start={selectedRange?.startUtf16}
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
onSelect={(event) =>
onSelectionChange?.({
startUtf16: event.currentTarget.selectionStart,
endUtf16: event.currentTarget.selectionEnd,
})
}
/> />
); );
}, },
@@ -115,7 +133,18 @@ vi.mock("../regex/execution/EngineSupervisor", () => ({
internallyAddedIndicesFlag: false, internallyAddedIndicesFlag: false,
internallyAddedGlobalFlag: false, internallyAddedGlobalFlag: false,
}, },
matches: [], matches: engineHarness.emitWholeFirstCharacterMatch
? [
{
matchNumber: 1,
value: request.subject.slice(0, 1),
valueStatus: "complete" as const,
range: { startUtf16: 0, endUtf16: 1 },
nativeRange: { start: 0, end: 1, unit: "utf16" as const },
captures: [],
},
]
: [],
diagnostics: [], diagnostics: [],
elapsedMs: 1, elapsedMs: 1,
truncated: false, truncated: false,
@@ -231,6 +260,7 @@ afterEach(() => {
syntaxHarness.patterns.length = 0; syntaxHarness.patterns.length = 0;
syntaxHarness.replacements.length = 0; syntaxHarness.replacements.length = 0;
engineHarness.requests.length = 0; engineHarness.requests.length = 0;
engineHarness.emitWholeFirstCharacterMatch = false;
}); });
describe("Workbench syntax coordination", () => { describe("Workbench syntax coordination", () => {
@@ -346,6 +376,79 @@ describe("Workbench syntax coordination", () => {
}); });
}); });
it("does not revive an old syntax snapshot when rapid edits return to the same text", async () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
const patternInput = screen.getByRole("textbox", {
name: "Regular expression pattern",
});
const initialPattern = (patternInput as HTMLTextAreaElement).value;
const runButton = screen.getByRole("button", { name: "Run" });
expect(runButton).toBeEnabled();
fireEvent.change(patternInput, { target: { value: "temporary" } });
fireEvent.change(patternInput, { target: { value: initialPattern } });
expect(runButton).toBeDisabled();
fireEvent.click(screen.getByRole("treeitem"));
expect(patternInput).toHaveAttribute("data-mark-count", "0");
await advanceParseDebounce();
await resolvePattern(1, []);
expect(patternInput).toHaveAttribute("data-mark-count", "0");
expect(runButton).toBeEnabled();
});
it("retains one exact result frame until the edited subject has a replacement frame", async () => {
vi.useFakeTimers();
engineHarness.emitWholeFirstCharacterMatch = true;
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
const subjectInput = screen.getByRole("textbox", { name: "Test text" });
const originalSubject = (subjectInput as HTMLTextAreaElement).value;
expect(subjectInput).toHaveAttribute("data-mark-count", "1");
expect(document.querySelector(".extraction-row")).toHaveTextContent("2");
fireEvent.change(subjectInput, { target: { value: "Z new subject" } });
expect(screen.getByTestId("stale-result-frame")).toHaveTextContent(
"bound to the last completed inputs",
);
expect(subjectInput).toHaveAttribute("data-mark-count", "0");
expect(document.querySelector(".extraction-row")).toHaveTextContent("2");
fireEvent.change(subjectInput, { target: { value: originalSubject } });
expect(screen.getByTestId("stale-result-frame")).toBeInTheDocument();
expect(subjectInput).toHaveAttribute("data-mark-count", "0");
fireEvent.change(subjectInput, { target: { value: "Z new subject" } });
(subjectInput as HTMLTextAreaElement).setSelectionRange(0, 1);
fireEvent.select(subjectInput);
expect(
document.querySelector(".extraction-row.is-selected"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId("stale-result-frame")).not.toBeInTheDocument();
expect(subjectInput).toHaveAttribute("data-mark-count", "1");
expect(document.querySelector(".extraction-row")).toHaveTextContent("Z");
});
it("captures a complete result and reruns only the selected saved test", async () => { it("captures a complete result and reruns only the selected saved test", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
render(<Workbench />); render(<Workbench />);

View File

@@ -22,6 +22,7 @@ import type {
} from "../regex/model/match"; } from "../regex/model/match";
import type { import type {
NormalizedRegexNode, NormalizedRegexNode,
RegexSyntaxRequest,
RegexSyntaxResult, RegexSyntaxResult,
ReplacementSyntaxResult, ReplacementSyntaxResult,
ReplacementToken, ReplacementToken,
@@ -105,6 +106,79 @@ type RunState =
| { readonly status: "timeout"; readonly message: string } | { readonly status: "timeout"; readonly message: string }
| { readonly status: "error"; readonly message: string }; | { readonly status: "error"; readonly message: string };
interface ReplacementSyntaxSnapshot {
readonly revision: number;
readonly flavour: RegexFlavourId;
readonly replacement: string;
readonly patternSyntax: RegexSyntaxResult;
readonly result: ReplacementSyntaxResult;
}
interface ResultFrame {
readonly revision: number;
readonly flavour: RegexFlavourId;
readonly flavourVersion: string;
readonly options: RegexEngineOptions;
readonly pattern: string;
readonly flags: readonly string[];
readonly subject: string;
readonly replacement: string;
readonly mode: WorkspaceMode;
readonly scanAll: boolean;
readonly syntaxRequest: RegexSyntaxRequest;
readonly syntax: RegexSyntaxResult;
readonly replacementSyntax?: ReplacementSyntaxResult;
readonly execution: RegexExecutionResult;
readonly replacementResult?: RegexReplacementResult;
}
function engineOptionsEqual(
left: RegexEngineOptions,
right: RegexEngineOptions,
): boolean {
const entries = Object.entries(left);
return (
entries.length === Object.keys(right).length &&
entries.every(([name, value]) => right[name] === value)
);
}
function resultFrameMatchesInput(
frame: ResultFrame | undefined,
revision: number,
input: {
readonly flavour: RegexFlavourId;
readonly flavourVersion: string;
readonly options: RegexEngineOptions;
readonly pattern: string;
readonly flags: readonly string[];
readonly subject: string;
readonly replacement: string;
readonly mode: WorkspaceMode;
readonly scanAll: boolean;
readonly syntax: RegexSyntaxResult | undefined;
readonly replacementSyntax: ReplacementSyntaxResult | undefined;
},
): frame is ResultFrame {
return (
frame !== undefined &&
frame.revision === revision &&
frame.flavour === input.flavour &&
frame.flavourVersion === input.flavourVersion &&
frame.pattern === input.pattern &&
frame.subject === input.subject &&
frame.replacement === input.replacement &&
frame.mode === input.mode &&
frame.scanAll === input.scanAll &&
frame.syntax === input.syntax &&
frame.flags.length === input.flags.length &&
frame.flags.every((flag, index) => flag === input.flags[index]) &&
engineOptionsEqual(frame.options, input.options) &&
(frame.mode !== "replace" ||
frame.replacementSyntax === input.replacementSyntax)
);
}
function findSmallestNode( function findSmallestNode(
node: NormalizedRegexNode, node: NormalizedRegexNode,
range: SourceRange, range: SourceRange,
@@ -401,11 +475,9 @@ export function Workbench() {
); );
const [syntaxSnapshot, setSyntaxSnapshot] = const [syntaxSnapshot, setSyntaxSnapshot] =
useState<PatternSyntaxSnapshot<RegexSyntaxResult>>(); useState<PatternSyntaxSnapshot<RegexSyntaxResult>>();
const [replacementSyntax, setReplacementSyntax] = const [replacementSyntaxSnapshot, setReplacementSyntaxSnapshot] =
useState<ReplacementSyntaxResult>(); useState<ReplacementSyntaxSnapshot>();
const [execution, setExecution] = useState<RegexExecutionResult>(); const [resultFrame, setResultFrame] = useState<ResultFrame>();
const [replacementResult, setReplacementResult] =
useState<RegexReplacementResult>();
const [selectedSyntax, setSelectedSyntax] = useState<NormalizedRegexNode>(); const [selectedSyntax, setSelectedSyntax] = useState<NormalizedRegexNode>();
const [requestedPatternRange, setRequestedPatternRange] = const [requestedPatternRange, setRequestedPatternRange] =
useState<SourceRange>(); useState<SourceRange>();
@@ -443,6 +515,10 @@ export function Workbench() {
const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false); const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
const [corpusSession, setCorpusSession] = useState(0); const [corpusSession, setCorpusSession] = useState(0);
const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 }); const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 });
const [syntaxGeneration, setSyntaxGeneration] = useState(0);
const [replacementSyntaxGeneration, setReplacementSyntaxGeneration] =
useState(0);
const [executionGeneration, setExecutionGeneration] = useState(0);
const [projectIdentity, setProjectIdentity] = useState(() => ({ const [projectIdentity, setProjectIdentity] = useState(() => ({
id: projectId(), id: projectId(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -490,9 +566,7 @@ export function Workbench() {
return testEngineSupervisor.current; return testEngineSupervisor.current;
}; };
const clearExecutionResults = useCallback(() => { const clearResultSelections = useCallback(() => {
setExecution(undefined);
setReplacementResult(undefined);
setSelectedExtraction(undefined); setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined); setSelectedCaptureRow(undefined);
setRequestedSubjectRange(undefined); setRequestedSubjectRange(undefined);
@@ -502,22 +576,24 @@ export function Workbench() {
const invalidateExecution = (preserveCurrentResult = false) => { const invalidateExecution = (preserveCurrentResult = false) => {
executionRevision.current += 1; executionRevision.current += 1;
setExecutionGeneration(executionRevision.current);
engineSupervisor.current?.cancel(); engineSupervisor.current?.cancel();
clearExecutionResults(); clearResultSelections();
if (!preserveCurrentResult) setCurrentResultDraft(undefined); if (!preserveCurrentResult) setCurrentResultDraft(undefined);
setRunState({ setRunState({
status: "idle", status: "idle",
message: "Inputs changed; previous results were cleared.", message:
"Inputs changed. The last completed result remains visible until the current inputs complete.",
}); });
}; };
const invalidatePatternSyntax = () => { const invalidatePatternSyntax = () => {
syntaxRevision.current += 1; syntaxRevision.current += 1;
replacementSyntaxRevision.current += 1; replacementSyntaxRevision.current += 1;
setSyntaxGeneration(syntaxRevision.current);
setReplacementSyntaxGeneration(replacementSyntaxRevision.current);
patternSyntaxSupervisor.current?.cancel(); patternSyntaxSupervisor.current?.cancel();
replacementSyntaxSupervisor.current?.cancel(); replacementSyntaxSupervisor.current?.cancel();
setSyntaxSnapshot(undefined);
setReplacementSyntax(undefined);
setSelectedSyntax(undefined); setSelectedSyntax(undefined);
setSelectedReplacementToken(undefined); setSelectedReplacementToken(undefined);
setSelectedReplacementContribution(undefined); setSelectedReplacementContribution(undefined);
@@ -539,8 +615,8 @@ export function Workbench() {
const changeReplacement = (value: string) => { const changeReplacement = (value: string) => {
invalidateExecution(); invalidateExecution();
replacementSyntaxRevision.current += 1; replacementSyntaxRevision.current += 1;
setReplacementSyntaxGeneration(replacementSyntaxRevision.current);
replacementSyntaxSupervisor.current?.cancel(); replacementSyntaxSupervisor.current?.cancel();
setReplacementSyntax(undefined);
setSelectedReplacementToken(undefined); setSelectedReplacementToken(undefined);
setSelectedReplacementContribution(undefined); setSelectedReplacementContribution(undefined);
setRequestedOutputRange(undefined); setRequestedOutputRange(undefined);
@@ -582,16 +658,20 @@ export function Workbench() {
options, options,
}; };
}, [flags, flavour, flavourVersion, options, pattern]); }, [flags, flavour, flavourVersion, options, pattern]);
const syntax = isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest) const syntax =
? syntaxSnapshot.result syntaxSnapshot?.revision === syntaxGeneration &&
: undefined; isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest)
? syntaxSnapshot.result
: undefined;
const presentedSyntax = syntax ?? syntaxSnapshot?.result;
const presentedSyntaxIsStale =
presentedSyntax !== undefined && syntax === undefined;
useEffect(() => { useEffect(() => {
const currentRevision = syntaxRevision.current; const currentRevision = syntaxRevision.current;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
if (pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) { if (pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
if (syntaxRevision.current !== currentRevision) return; if (syntaxRevision.current !== currentRevision) return;
setSyntaxSnapshot(undefined);
setRunState({ setRunState({
status: "error", status: "error",
message: `Pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`, message: `Pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`,
@@ -613,7 +693,6 @@ export function Workbench() {
: undefined, : undefined,
); );
if (!result.accepted) { if (!result.accepted) {
clearExecutionResults();
setRunState({ setRunState({
status: "error", status: "error",
message: message:
@@ -622,7 +701,6 @@ export function Workbench() {
} else if ( } else if (
result.captures.length > DEFAULT_REGEX_LIMITS.maximumCaptureGroups result.captures.length > DEFAULT_REGEX_LIMITS.maximumCaptureGroups
) { ) {
clearExecutionResults();
setRunState({ setRunState({
status: "error", status: "error",
message: captureGroupLimitMessage(result.captures.length), message: captureGroupLimitMessage(result.captures.length),
@@ -637,7 +715,6 @@ export function Workbench() {
) { ) {
return; return;
} }
clearExecutionResults();
setRunState({ setRunState({
status: "error", status: "error",
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : String(error),
@@ -645,20 +722,30 @@ export function Workbench() {
}); });
}, DEFAULT_REGEX_LIMITS.liveParseDebounceMs); }, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [clearExecutionResults, pattern, syntaxRequest]); }, [pattern, syntaxRequest]);
useEffect(() => { useEffect(() => {
if (!syntax) return; if (!syntax) return;
const currentRevision = ++replacementSyntaxRevision.current; const currentRevision = replacementSyntaxRevision.current;
const patternSyntax = syntax;
void replacementSyntaxEngine() void replacementSyntaxEngine()
.parseReplacement({ .parseReplacement({
flavour, flavour,
replacement, replacement,
captureMetadata: syntax.captures, captureMetadata: patternSyntax.captures,
}) })
.then((result) => { .then((result) => {
if (replacementSyntaxRevision.current !== currentRevision) return; if (replacementSyntaxRevision.current !== currentRevision) return;
setReplacementSyntax(result); setReplacementSyntaxSnapshot({
revision: currentRevision,
flavour,
replacement,
patternSyntax,
result,
});
setSelectedReplacementToken(undefined);
setSelectedReplacementContribution(undefined);
setRequestedOutputRange(undefined);
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (replacementSyntaxRevision.current !== currentRevision) return; if (replacementSyntaxRevision.current !== currentRevision) return;
@@ -671,6 +758,22 @@ export function Workbench() {
}); });
}, [flavour, replacement, syntax]); }, [flavour, replacement, syntax]);
const replacementSyntax =
replacementSyntaxSnapshot?.revision === replacementSyntaxGeneration &&
replacementSyntaxSnapshot.flavour === flavour &&
replacementSyntaxSnapshot.replacement === replacement &&
replacementSyntaxSnapshot.patternSyntax === syntax
? replacementSyntaxSnapshot.result
: undefined;
const presentedReplacementSyntax =
replacementSyntax ?? replacementSyntaxSnapshot?.result;
const presentedReplacementSyntaxIsStale =
presentedReplacementSyntax !== undefined && replacementSyntax === undefined;
const presentedReplacementFlavour =
replacementSyntax !== undefined
? flavour
: replacementSyntaxSnapshot?.flavour;
const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]); const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]);
const makeRequest = useCallback( const makeRequest = useCallback(
@@ -704,7 +807,6 @@ export function Workbench() {
window.clearTimeout(liveExecutionTimer.current); window.clearTimeout(liveExecutionTimer.current);
liveExecutionTimer.current = undefined; liveExecutionTimer.current = undefined;
} }
clearExecutionResults();
setCurrentResultDraft(undefined); setCurrentResultDraft(undefined);
if ( if (
!syntax?.accepted || !syntax?.accepted ||
@@ -744,6 +846,23 @@ export function Workbench() {
return; return;
} }
const requestRevision = ++executionRevision.current; const requestRevision = ++executionRevision.current;
setExecutionGeneration(requestRevision);
const currentSyntax = syntax;
const currentReplacementSyntax = replacementSyntax;
const frameIdentity = {
revision: requestRevision,
flavour,
flavourVersion,
options: { ...options },
pattern,
flags: [...flags],
subject,
replacement,
mode,
scanAll,
syntaxRequest,
syntax: currentSyntax,
} as const;
const limit = const limit =
kind === "live" kind === "live"
? DEFAULT_REGEX_LIMITS.liveExecutionTimeoutMs ? DEFAULT_REGEX_LIMITS.liveExecutionTimeoutMs
@@ -766,16 +885,13 @@ export function Workbench() {
limit, limit,
); );
if (executionRevision.current !== requestRevision) return; if (executionRevision.current !== requestRevision) return;
setExecution(result.execution);
if (!result.execution.accepted) { if (!result.execution.accepted) {
setReplacementResult(undefined);
setRunState({ setRunState({
status: "error", status: "error",
message: engineRejectionMessage(result.execution), message: engineRejectionMessage(result.execution),
}); });
return; return;
} }
setReplacementResult(result);
const commonDraft = { const commonDraft = {
name: "Current replacement result", name: "Current replacement result",
pattern, pattern,
@@ -825,11 +941,17 @@ export function Workbench() {
: undefined; : undefined;
resultDraftNotice = decision.notice; resultDraftNotice = decision.notice;
setCurrentResultDraft(resultDraft); setCurrentResultDraft(resultDraft);
setResultFrame({
...frameIdentity,
replacementSyntax: currentReplacementSyntax,
execution: result.execution,
replacementResult: result,
});
clearResultSelections();
elapsedMs = result.execution.elapsedMs; elapsedMs = result.execution.elapsedMs;
} else { } else {
const result = await executionEngine().execute(makeRequest(), limit); const result = await executionEngine().execute(makeRequest(), limit);
if (executionRevision.current !== requestRevision) return; if (executionRevision.current !== requestRevision) return;
setExecution(result);
if (!result.accepted) { if (!result.accepted) {
setRunState({ setRunState({
status: "error", status: "error",
@@ -861,6 +983,11 @@ export function Workbench() {
} }
: undefined, : undefined,
); );
setResultFrame({
...frameIdentity,
execution: result,
});
clearResultSelections();
elapsedMs = result.elapsedMs; elapsedMs = result.elapsedMs;
} }
setRunState({ setRunState({
@@ -871,7 +998,6 @@ export function Workbench() {
}); });
} catch (error) { } catch (error) {
if (executionRevision.current !== requestRevision) return; if (executionRevision.current !== requestRevision) return;
clearExecutionResults();
const timedOut = const timedOut =
error instanceof WorkerRequestError && error.kind === "timeout"; error instanceof WorkerRequestError && error.kind === "timeout";
setRunState({ setRunState({
@@ -886,20 +1012,22 @@ export function Workbench() {
}, },
[ [
activeTestConfiguration, activeTestConfiguration,
clearExecutionResults, clearResultSelections,
flags,
flavour,
flavourVersion,
makeRequest, makeRequest,
mode, mode,
options,
pattern,
replacement, replacement,
replacementSyntax?.accepted, replacementSyntax,
scanAll, scanAll,
subjectBytes,
subject, subject,
syntax?.accepted, subjectBytes,
syntax?.captures.length, syntax,
syntaxSnapshot, syntaxSnapshot,
syntaxRequest, syntaxRequest,
flags,
pattern,
tests, tests,
timeoutMs, timeoutMs,
], ],
@@ -943,22 +1071,49 @@ export function Workbench() {
syntax?.accepted, syntax?.accepted,
]); ]);
const resultFrameIsCurrent = resultFrameMatchesInput(
resultFrame,
executionGeneration,
{
flavour,
flavourVersion,
options,
pattern,
flags,
subject,
replacement,
mode,
scanAll,
syntax,
replacementSyntax,
},
);
const resultFrameIsStale = resultFrame !== undefined && !resultFrameIsCurrent;
const execution = resultFrame?.execution;
const replacementResult = resultFrame?.replacementResult;
const resultSubject = resultFrame?.subject ?? subject;
const resultSyntax = resultFrame?.syntax;
const extraction = useMemo( const extraction = useMemo(
() => buildExtractionTree(execution?.matches ?? [], syntax?.captures ?? []), () =>
[execution?.matches, syntax?.captures], buildExtractionTree(
execution?.matches ?? [],
resultSyntax?.captures ?? [],
),
[execution?.matches, resultSyntax?.captures],
); );
const captureRows = useMemo( const captureRows = useMemo(
() => buildCaptureRows(execution?.matches ?? [], subject), () => buildCaptureRows(execution?.matches ?? [], resultSubject),
[execution?.matches, subject], [execution?.matches, resultSubject],
); );
const repeatedCaptures = useMemo( const repeatedCaptures = useMemo(
() => () =>
new Set( new Set(
syntax?.captures resultSyntax?.captures
.filter((capture) => capture.repeated) .filter((capture) => capture.repeated)
.map((capture) => capture.number) ?? [], .map((capture) => capture.number) ?? [],
), ),
[syntax?.captures], [resultSyntax?.captures],
); );
const selectedSubjectRange = useMemo( const selectedSubjectRange = useMemo(
() => () =>
@@ -971,8 +1126,12 @@ export function Workbench() {
[selectedCaptureRow, selectedExtraction?.range], [selectedCaptureRow, selectedExtraction?.range],
); );
const subjectMarkSummary = useMemo( const subjectMarkSummary = useMemo(
() => subjectMarks(execution, selectedSubjectRange), () =>
[execution, selectedSubjectRange], subjectMarks(
resultFrameIsCurrent ? execution : undefined,
resultFrameIsCurrent ? selectedSubjectRange : undefined,
),
[execution, resultFrameIsCurrent, selectedSubjectRange],
); );
const patternMarkSummary = useMemo( const patternMarkSummary = useMemo(
() => syntaxMarks(syntax, selectedSyntax), () => syntaxMarks(syntax, selectedSyntax),
@@ -980,15 +1139,19 @@ export function Workbench() {
); );
const replacementPreview = useMemo( const replacementPreview = useMemo(
() => () =>
replacementSyntax?.accepted === true && replacementResult resultFrame?.replacementSyntax?.accepted === true && replacementResult
? buildReplacementPreview(replacementSyntax, replacementResult, subject) ? buildReplacementPreview(
resultFrame.replacementSyntax,
replacementResult,
resultSubject,
)
: undefined, : undefined,
[replacementResult, replacementSyntax, subject], [replacementResult, resultFrame, resultSubject],
); );
const diagnostics = [ const diagnostics = [
...(syntax?.diagnostics ?? []), ...(syntax?.diagnostics ?? []),
...(replacementSyntax?.diagnostics ?? []), ...(replacementSyntax?.diagnostics ?? []),
...(execution?.diagnostics ?? []), ...(resultFrameIsCurrent ? (execution?.diagnostics ?? []) : []),
]; ];
const currentProject: RegexProjectV1 = { const currentProject: RegexProjectV1 = {
schemaVersion: 1, schemaVersion: 1,
@@ -1029,14 +1192,14 @@ export function Workbench() {
); );
setSelectedCaptureRow(captureRow); setSelectedCaptureRow(captureRow);
setRequestedSubjectRange( setRequestedSubjectRange(
requestEditorSelection && node.range resultFrameIsCurrent && requestEditorSelection && node.range
? { ? {
startUtf16: node.range.startUtf16, startUtf16: node.range.startUtf16,
endUtf16: node.range.endUtf16, endUtf16: node.range.endUtf16,
} }
: undefined, : undefined,
); );
if (node.groupNumber !== undefined) { if (resultFrameIsCurrent && node.groupNumber !== undefined) {
const definition = syntax?.captures.find( const definition = syntax?.captures.find(
(capture) => capture.number === node.groupNumber, (capture) => capture.number === node.groupNumber,
); );
@@ -1052,16 +1215,29 @@ export function Workbench() {
: undefined, : undefined,
); );
} }
} else { } else if (resultFrameIsCurrent) {
setSelectedSyntax(syntax?.root); setSelectedSyntax(syntax?.root);
setRequestedPatternRange(syntax?.root.range); setRequestedPatternRange(syntax?.root.range);
} else {
setSelectedSyntax(undefined);
setRequestedPatternRange(undefined);
} }
}; };
const selectSyntaxNode = ( const selectSyntaxNode = (
node: NormalizedRegexNode, node: NormalizedRegexNode,
requestEditorSelection = true, requestEditorSelection = true,
mapsToCurrentPattern = true,
) => { ) => {
if (!mapsToCurrentPattern) {
setSelectedSyntax(undefined);
setRequestedPatternRange(undefined);
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
setRequestedSubjectRange(undefined);
return;
}
setSelectedSyntax(node); setSelectedSyntax(node);
setRequestedPatternRange( setRequestedPatternRange(
requestEditorSelection requestEditorSelection
@@ -1072,6 +1248,13 @@ export function Workbench() {
: undefined, : undefined,
); );
if (!resultFrameIsCurrent) {
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
setRequestedSubjectRange(undefined);
return;
}
const enclosingCapture = syntax?.captures const enclosingCapture = syntax?.captures
.filter( .filter(
(capture) => (capture) =>
@@ -1129,7 +1312,9 @@ export function Workbench() {
contribution: ReplacementContributionPreview, contribution: ReplacementContributionPreview,
) => { ) => {
setSelectedReplacementContribution(contribution); setSelectedReplacementContribution(contribution);
setSelectedReplacementToken(contribution.token); setSelectedReplacementToken(
resultFrameIsCurrent ? contribution.token : undefined,
);
setRequestedOutputRange(contribution.outputRange); setRequestedOutputRange(contribution.outputRange);
const match = execution?.matches.find( const match = execution?.matches.find(
@@ -1163,7 +1348,7 @@ export function Workbench() {
} else { } else {
setSelectedExtraction(undefined); setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined); setSelectedCaptureRow(undefined);
setRequestedSubjectRange(match?.range); setRequestedSubjectRange(resultFrameIsCurrent ? match?.range : undefined);
} }
}; };
@@ -1416,6 +1601,7 @@ export function Workbench() {
const importedFlavour = AVAILABLE_REGEX_FLAVOURS.require(project.flavour); const importedFlavour = AVAILABLE_REGEX_FLAVOURS.require(project.flavour);
invalidatePatternSyntax(); invalidatePatternSyntax();
executionRevision.current += 1; executionRevision.current += 1;
setExecutionGeneration(executionRevision.current);
engineSupervisor.current?.cancel(); engineSupervisor.current?.cancel();
cancelTestRun(); cancelTestRun();
setFlavour(importedFlavour.id); setFlavour(importedFlavour.id);
@@ -1442,7 +1628,10 @@ export function Workbench() {
setTestResults(new Map()); setTestResults(new Map());
setLive(false); setLive(false);
setScanAll(project.ui?.scanAll ?? false); setScanAll(project.ui?.scanAll ?? false);
clearExecutionResults(); setResultFrame(undefined);
setSyntaxSnapshot(undefined);
setReplacementSyntaxSnapshot(undefined);
clearResultSelections();
setSelectedSyntax(undefined); setSelectedSyntax(undefined);
setRequestedPatternRange(undefined); setRequestedPatternRange(undefined);
setSelectedReplacementToken(undefined); setSelectedReplacementToken(undefined);
@@ -1973,7 +2162,7 @@ export function Workbench() {
> >
<CapabilityPanel <CapabilityPanel
syntax={syntax} syntax={syntax}
execution={execution} execution={resultFrameIsCurrent ? execution : undefined}
onClose={() => setShowCapabilities(false)} onClose={() => setShowCapabilities(false)}
/> />
</ModalDialog> </ModalDialog>
@@ -2041,10 +2230,17 @@ export function Workbench() {
<span>{runState.status}</span> <span>{runState.status}</span>
<strong>{runState.message}</strong> <strong>{runState.message}</strong>
{execution?.accepted ? ( {execution?.accepted ? (
<small> <small
className={resultFrameIsStale ? "is-stale" : undefined}
data-testid={resultFrameIsStale ? "stale-result-frame" : undefined}
>
{resultFrameIsStale ? "Stale result · " : ""}
{execution.matches.length.toLocaleString()} matches ·{" "} {execution.matches.length.toLocaleString()} matches ·{" "}
{execution.elapsedMs.toFixed(2)} ms · native offsets{" "} {execution.elapsedMs.toFixed(2)} ms · native offsets{" "}
{execution.engine.offsetUnit} {execution.engine.offsetUnit}
{resultFrameIsStale
? " · bound to the last completed inputs; no ranges are applied to current editors"
: ""}
</small> </small>
) : null} ) : null}
</div> </div>
@@ -2097,10 +2293,17 @@ export function Workbench() {
/> />
</section> </section>
<ExplanationTree <ExplanationTree
root={syntax?.root} root={presentedSyntax?.root}
selectedId={selectedSyntax?.id} selectedId={selectedSyntax?.id}
flags={flags} flags={
onSelect={(node) => selectSyntaxNode(node)} presentedSyntaxIsStale
? (syntaxSnapshot?.request.flags ?? [])
: flags
}
stale={presentedSyntaxIsStale}
onSelect={(node) =>
selectSyntaxNode(node, true, !presentedSyntaxIsStale)
}
/> />
</div> </div>
{mode !== "tests" && mode !== "corpus" ? ( {mode !== "tests" && mode !== "corpus" ? (
@@ -2146,6 +2349,11 @@ export function Workbench() {
onChange={changeSubject} onChange={changeSubject}
onSelectionChange={(range) => { onSelectionChange={(range) => {
setRequestedSubjectRange(undefined); setRequestedSubjectRange(undefined);
if (!resultFrameIsCurrent) {
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
return;
}
const node = findSmallestExtractionNode(extraction, range); const node = findSmallestExtractionNode(extraction, range);
if (node) { if (node) {
selectExtraction(node, false); selectExtraction(node, false);
@@ -2180,7 +2388,7 @@ export function Workbench() {
{mode === "match" ? ( {mode === "match" ? (
<CaptureTable <CaptureTable
rows={captureRows} rows={captureRows}
subject={subject} subject={resultSubject}
sourceResultTruncated={execution?.truncated ?? false} sourceResultTruncated={execution?.truncated ?? false}
selectedId={selectedCaptureRow?.id} selectedId={selectedCaptureRow?.id}
onSelect={(row) => { onSelect={(row) => {
@@ -2194,7 +2402,7 @@ export function Workbench() {
setSelectedCaptureRow(row); setSelectedCaptureRow(row);
setSelectedExtraction(undefined); setSelectedExtraction(undefined);
setRequestedSubjectRange( setRequestedSubjectRange(
row.start === undefined !resultFrameIsCurrent || row.start === undefined
? undefined ? undefined
: { : {
startUtf16: row.start, startUtf16: row.start,
@@ -2202,9 +2410,11 @@ export function Workbench() {
}, },
); );
} }
const definition = syntax?.captures.find( const definition = resultFrameIsCurrent
(capture) => capture.number === row.groupNumber, ? syntax?.captures.find(
); (capture) => capture.number === row.groupNumber,
)
: undefined;
if (definition && syntax) { if (definition && syntax) {
const selected = findSmallestNode(syntax.root, definition.range); const selected = findSmallestNode(syntax.root, definition.range);
setSelectedSyntax(selected); setSelectedSyntax(selected);
@@ -2226,7 +2436,11 @@ export function Workbench() {
replacement={replacement} replacement={replacement}
onReplacementChange={changeReplacement} onReplacementChange={changeReplacement}
syntax={replacementSyntax} syntax={replacementSyntax}
presentedSyntax={presentedReplacementSyntax}
syntaxStale={presentedReplacementSyntaxIsStale}
presentedFlavour={presentedReplacementFlavour}
result={replacementResult} result={replacementResult}
resultFlavour={resultFrame?.flavour}
preview={replacementPreview} preview={replacementPreview}
selectedToken={selectedReplacementToken} selectedToken={selectedReplacementToken}
selectedContributionId={selectedReplacementContribution?.id} selectedContributionId={selectedReplacementContribution?.id}
@@ -2244,7 +2458,7 @@ export function Workbench() {
templateSource={listTemplate} templateSource={listTemplate}
onTemplateChange={setListTemplate} onTemplateChange={setListTemplate}
matches={execution?.matches ?? []} matches={execution?.matches ?? []}
subject={subject} subject={resultSubject}
sourceResultTruncated={execution?.truncated ?? false} sourceResultTruncated={execution?.truncated ?? false}
/> />
) : null} ) : null}

View File

@@ -161,6 +161,24 @@ textarea:focus-visible,
color: color-mix(in srgb, var(--regex-mint) 70%, var(--toolbox-text)); color: color-mix(in srgb, var(--regex-mint) 70%, var(--toolbox-text));
} }
.provenance-badge.is-stale,
.run-status > small.is-stale {
color: color-mix(in srgb, var(--regex-warning) 78%, var(--toolbox-text));
}
.provenance-badge.is-stale {
border-color: color-mix(
in srgb,
var(--regex-warning) 45%,
var(--toolbox-border)
);
background: color-mix(
in srgb,
var(--regex-warning) 9%,
var(--toolbox-surface)
);
}
.primary-button, .primary-button,
.secondary-button, .secondary-button,
.icon-button { .icon-button {

View File

@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.regex-tools", "id": "de.add-ideas.regex-tools",
"name": "Regex Tools", "name": "Regex Tools",
"version": "0.4.0", "version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.", "description": "Develop, explain, test and apply regular expressions locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",

View File

@@ -1,3 +1,3 @@
export const APPLICATION_VERSION = "0.4.0"; export const APPLICATION_VERSION = "0.4.1";
export const SYNTAX_PROFILE = "community"; export const SYNTAX_PROFILE = "community";
export const REGEXPP_VERSION = "4.12.2"; export const REGEXPP_VERSION = "4.12.2";

View File

@@ -446,6 +446,7 @@ test("rapid pattern and replacement edits cannot run with stale syntax", async (
await page.goto("./"); await page.goto("./");
await waitForReady(page); await waitForReady(page);
await page.getByLabel("Live").uncheck(); await page.getByLabel("Live").uncheck();
const initialExtractionCount = await page.locator(".extraction-row").count();
await page.getByRole("button", { name: "Replace", exact: true }).click(); await page.getByRole("button", { name: "Replace", exact: true }).click();
await setEditor(page, "editor-regular-expression-pattern", "(?<stale>\\w+)"); await setEditor(page, "editor-regular-expression-pattern", "(?<stale>\\w+)");
@@ -462,7 +463,10 @@ test("rapid pattern and replacement edits cannot run with stale syntax", async (
await expect(runButton).toBeDisabled(); await expect(runButton).toBeDisabled();
await runButton.evaluate((button: HTMLButtonElement) => button.click()); await runButton.evaluate((button: HTMLButtonElement) => button.click());
await expect(page.locator(".run-status")).not.toHaveClass(/status-running/u); await expect(page.locator(".run-status")).not.toHaveClass(/status-running/u);
await expect(page.locator(".extraction-row")).toHaveCount(0); await expect(page.locator(".extraction-row")).toHaveCount(
initialExtractionCount,
);
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await expect(runButton).toBeEnabled(); await expect(runButton).toBeEnabled();
const currentNamedToken = page.locator(".token-row").filter({ const currentNamedToken = page.locator(".token-row").filter({
@@ -484,22 +488,31 @@ test("rapid pattern and replacement edits cannot run with stale syntax", async (
).toHaveText("[A] [B]"); ).toHaveText("[A] [B]");
}); });
test("manual editing clears results that no longer describe the inputs", async ({ test("manual editing retains an explicitly stale result without applying its ranges", async ({
page, page,
}) => { }) => {
await page.goto("./"); await page.goto("./");
await waitForReady(page); await waitForReady(page);
await page.getByLabel("Live").uncheck(); await page.getByLabel("Live").uncheck();
const extractionRows = page.locator(".extraction-row");
const captureRows = page.locator(".capture-table-scroll tbody tr");
const initialExtractionCount = await extractionRows.count();
const initialCaptureCount = await captureRows.count();
expect(initialExtractionCount).toBeGreaterThan(0);
expect(initialCaptureCount).toBeGreaterThan(0);
await setEditor(page, "editor-regular-expression-pattern", "nomatch"); await setEditor(page, "editor-regular-expression-pattern", "nomatch");
await expect(page.locator(".run-status")).toHaveClass(/status-idle/u); await expect(page.locator(".run-status")).toHaveClass(/status-idle/u);
await expect(page.locator(".run-status")).toContainText( await expect(page.locator(".run-status")).toContainText(
"previous results were cleared", "last completed result remains visible until the current inputs complete",
); );
await expect(page.locator(".run-status small")).toHaveCount(0); await expect(page.getByTestId("stale-result-frame")).toContainText(
await expect(page.locator(".extraction-row")).toHaveCount(0); "no ranges are applied to current editors",
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0); );
await expect(extractionRows).toHaveCount(initialExtractionCount);
await expect(captureRows).toHaveCount(initialCaptureCount);
await expect(page.locator(".cm-subject-match")).toHaveCount(0); await expect(page.locator(".cm-subject-match")).toHaveCount(0);
}); });
@@ -509,6 +522,10 @@ test("capture-group resource limits block execution visibly", async ({
await page.goto("./"); await page.goto("./");
await waitForReady(page); await waitForReady(page);
await page.getByLabel("Live").uncheck(); await page.getByLabel("Live").uncheck();
const initialExtractionCount = await page.locator(".extraction-row").count();
const initialCaptureCount = await page
.locator(".capture-table-scroll tbody tr")
.count();
await setEditor( await setEditor(
page, page,
@@ -520,8 +537,14 @@ test("capture-group resource limits block execution visibly", async ({
await expect(page.locator(".run-status")).toContainText( await expect(page.locator(".run-status")).toContainText(
"above the 1,000 group execution limit", "above the 1,000 group execution limit",
); );
await expect(page.locator(".run-status small")).toHaveCount(0); await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await expect(page.locator(".extraction-row")).toHaveCount(0); await expect(page.locator(".extraction-row")).toHaveCount(
initialExtractionCount,
);
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(
initialCaptureCount,
);
await expect(page.locator(".cm-subject-match")).toHaveCount(0);
}); });
test("large valid patterns bound syntax tree nodes and editor decorations", async ({ test("large valid patterns bound syntax tree nodes and editor decorations", async ({
@@ -779,6 +802,25 @@ test("replacement and list modes use native and typed templates", async ({
page.getByTestId("editor-replacement-output").locator(".cm-selected-range"), page.getByTestId("editor-replacement-output").locator(".cm-selected-range"),
).toHaveCount(1); ).toHaveCount(1);
await page.getByLabel("Live").uncheck();
await setEditor(page, "editor-replacement-template", "[$<user> — $<date>]");
await expect(page.getByTestId("stale-replacement-syntax")).not.toBeVisible();
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await userContribution.click();
await expect(
page
.getByTestId("editor-replacement-template")
.locator(".cm-selected-range"),
).toHaveCount(0);
await expect(
page
.getByTestId("editor-regular-expression-pattern")
.locator(".cm-selected-range"),
).toHaveCount(0);
await expect(
page.getByTestId("editor-test-text").locator(".cm-selected-range"),
).toHaveCount(0);
await page.getByRole("button", { name: "List", exact: true }).click(); await page.getByRole("button", { name: "List", exact: true }).click();
await expect( await expect(
page.getByRole("heading", { name: "Generated rows" }), page.getByRole("heading", { name: "Generated rows" }),
@@ -1117,6 +1159,10 @@ test("a timeout kills the worker and the next request uses a fresh engine", asyn
await page.goto("./"); await page.goto("./");
await waitForReady(page); await waitForReady(page);
await page.getByLabel("Live").uncheck(); await page.getByLabel("Live").uncheck();
const initialExtractionCount = await page.locator(".extraction-row").count();
const initialCaptureCount = await page
.locator(".capture-table-scroll tbody tr")
.count();
await page.getByLabel("Manual limit").selectOption("250"); await page.getByLabel("Manual limit").selectOption("250");
await setEditor(page, "editor-regular-expression-pattern", "^(a|aa)+$"); await setEditor(page, "editor-regular-expression-pattern", "^(a|aa)+$");
await setEditor(page, "editor-test-text", `${"a".repeat(48)}!`); await setEditor(page, "editor-test-text", `${"a".repeat(48)}!`);
@@ -1127,9 +1173,13 @@ test("a timeout kills the worker and the next request uses a fresh engine", asyn
await expect(page.locator(".run-status")).toContainText( await expect(page.locator(".run-status")).toContainText(
"worker was terminated", "worker was terminated",
); );
await expect(page.locator(".run-status small")).toHaveCount(0); await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await expect(page.locator(".extraction-row")).toHaveCount(0); await expect(page.locator(".extraction-row")).toHaveCount(
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0); initialExtractionCount,
);
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(
initialCaptureCount,
);
await expect(page.locator(".cm-subject-match")).toHaveCount(0); await expect(page.locator(".cm-subject-match")).toHaveCount(0);
await setEditor(page, "editor-regular-expression-pattern", "^ok$"); await setEditor(page, "editor-regular-expression-pattern", "^ok$");