fix: retain completed results during live editing
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,6 +2,16 @@
|
||||
|
||||
## 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
|
||||
|
||||
- Expand the workbench from four to twelve selectable execution profiles:
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
ECMAScript 2025 syntax supplied by regexpp 4.12.2;
|
||||
@@ -99,6 +99,9 @@ relabeled Scala engine.
|
||||
- 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.
|
||||
- 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
|
||||
profile returns its native range records through a bounded fixed bridge.
|
||||
- Named, numbered, optional, empty and repeated-final captures are represented.
|
||||
|
||||
10
SOURCE.md
10
SOURCE.md
@@ -1,10 +1,10 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Regex Tools
|
||||
- Release version: `0.4.0`
|
||||
- Release tag: `v0.4.0`
|
||||
- Release version: `0.4.1`
|
||||
- Release tag: `v0.4.1`
|
||||
- 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
|
||||
- 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
|
||||
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,
|
||||
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,
|
||||
@@ -91,4 +91,4 @@ TeaVM-backed Scala/JVM compatibility profile.
|
||||
|
||||
Earlier releases remain available under their historical tags and artifact
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
history: the current adapter deliberately normalizes only the final record and
|
||||
reports `captureHistory: false`. The UI labels the limitation and does not
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
explanation only; the named runtime compiler and replacement operation remain
|
||||
authoritative.
|
||||
|
||||
@@ -11,7 +11,7 @@ selected real ECMAScript or PCRE2 worker. It can preserve:
|
||||
authoritative, untruncated output.
|
||||
|
||||
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
|
||||
lexical provider as an execution oracle.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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/`.
|
||||
|
||||
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
|
||||
overwritten or reused.
|
||||
|
||||
@@ -4,7 +4,7 @@ Inspection date: 2026-07-27.
|
||||
|
||||
| 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 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. |
|
||||
@@ -30,6 +30,6 @@ Inspection date: 2026-07-27.
|
||||
| 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 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
|
||||
files are authoritative for the exact binary distribution.
|
||||
|
||||
@@ -18,8 +18,8 @@ on publication failure, replaces an existing exact version only with
|
||||
`--force`, and produces:
|
||||
|
||||
```text
|
||||
release/regex-tools-0.4.0.zip
|
||||
release/regex-tools-0.4.0.zip.sha256
|
||||
release/regex-tools-0.4.1.zip
|
||||
release/regex-tools-0.4.1.zip.sha256
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
historical immutable artifacts. Creating v0.4.0 must not replace, rename or
|
||||
remove them.
|
||||
The existing v0.1.0 through v0.4.0 ZIPs and matching sidecars are historical
|
||||
immutable artifacts. Creating v0.4.1 must not replace, rename or remove them.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "regex-tools",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "regex-tools",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "regex-tools",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.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.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
@@ -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 gplVersion3TextSha256 =
|
||||
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
|
||||
const expectedApplicationVersion = "0.4.0";
|
||||
const expectedApplicationVersion = "0.4.1";
|
||||
const requiredRootFiles = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ExplanationTreeProps {
|
||||
readonly root?: NormalizedRegexNode;
|
||||
readonly selectedId?: string;
|
||||
readonly flags?: readonly string[];
|
||||
readonly stale?: boolean;
|
||||
readonly onSelect: (node: NormalizedRegexNode) => void;
|
||||
}
|
||||
|
||||
@@ -165,6 +166,7 @@ export function ExplanationTree({
|
||||
root,
|
||||
selectedId,
|
||||
flags = [],
|
||||
stale = false,
|
||||
onSelect,
|
||||
}: ExplanationTreeProps) {
|
||||
const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]);
|
||||
@@ -191,8 +193,18 @@ export function ExplanationTree({
|
||||
<p className="eyebrow">Pattern structure</p>
|
||||
<h2 id="explanation-heading">Explanation tree</h2>
|
||||
</div>
|
||||
<span className="provenance-badge">
|
||||
Deterministic · syntax provider
|
||||
<span
|
||||
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>
|
||||
</header>
|
||||
{totalNodes > renderedNodes ? (
|
||||
|
||||
@@ -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", () => {
|
||||
renderResult({
|
||||
execution: { ...execution, truncated: true },
|
||||
|
||||
@@ -71,7 +71,11 @@ export function ReplacementPanel({
|
||||
replacement,
|
||||
onReplacementChange,
|
||||
syntax,
|
||||
presentedSyntax,
|
||||
syntaxStale = false,
|
||||
presentedFlavour,
|
||||
result,
|
||||
resultFlavour,
|
||||
preview,
|
||||
selectedToken,
|
||||
selectedContributionId,
|
||||
@@ -83,7 +87,11 @@ export function ReplacementPanel({
|
||||
readonly replacement: string;
|
||||
readonly onReplacementChange: (value: string) => void;
|
||||
readonly syntax?: ReplacementSyntaxResult;
|
||||
readonly presentedSyntax?: ReplacementSyntaxResult;
|
||||
readonly syntaxStale?: boolean;
|
||||
readonly presentedFlavour?: RegexFlavourId;
|
||||
readonly result?: RegexReplacementResult;
|
||||
readonly resultFlavour?: RegexFlavourId;
|
||||
readonly preview?: ReplacementPreview;
|
||||
readonly selectedToken?: ReplacementToken;
|
||||
readonly selectedContributionId?: string;
|
||||
@@ -96,8 +104,16 @@ export function ReplacementPanel({
|
||||
const activeFlavour =
|
||||
flavour ?? result?.execution.engine.flavour ?? "ecmascript";
|
||||
const engineLabel = TEMPLATE_FLAVOUR_LABELS[activeFlavour];
|
||||
const retainedTokens = syntax?.tokens ?? [];
|
||||
const editorTokens = retainedTokens.slice(
|
||||
const presentedEngineLabel =
|
||||
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,
|
||||
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks,
|
||||
);
|
||||
@@ -129,7 +145,7 @@ export function ReplacementPanel({
|
||||
maximumLengthUtf16={
|
||||
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
|
||||
}
|
||||
selectedRange={selectedToken?.range}
|
||||
selectedRange={syntaxStale ? undefined : selectedToken?.range}
|
||||
marks={[
|
||||
...editorTokens.map((token) => ({
|
||||
id: token.id,
|
||||
@@ -137,7 +153,7 @@ export function ReplacementPanel({
|
||||
className: `cm-replacement-token cm-replacement-${token.kind}`,
|
||||
label: token.explanation,
|
||||
})),
|
||||
...(selectedToken
|
||||
...(!syntaxStale && selectedToken
|
||||
? [
|
||||
{
|
||||
id: "selected-replacement-token",
|
||||
@@ -163,7 +179,7 @@ export function ReplacementPanel({
|
||||
unit limit.
|
||||
</p>
|
||||
) : null}
|
||||
{retainedTokens.length >
|
||||
{currentTokens.length >
|
||||
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks ? (
|
||||
<p
|
||||
className="render-limit-banner"
|
||||
@@ -171,7 +187,7 @@ export function ReplacementPanel({
|
||||
>
|
||||
<strong>Editor decorations limited.</strong> Showing the first{" "}
|
||||
{TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks.toLocaleString()}{" "}
|
||||
of {retainedTokens.length.toLocaleString()} complete replacement
|
||||
of {currentTokens.length.toLocaleString()} complete replacement
|
||||
tokens.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -182,9 +198,24 @@ export function ReplacementPanel({
|
||||
>
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Derived token explanation</p>
|
||||
<p className="eyebrow">
|
||||
{presentedEngineLabel} derived token explanation
|
||||
</p>
|
||||
<h2 id="replacement-tree-heading">Replacement tokens</h2>
|
||||
</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>
|
||||
{renderedTokens.length > 0 ? (
|
||||
<>
|
||||
@@ -222,12 +253,12 @@ export function ReplacementPanel({
|
||||
</>
|
||||
) : (
|
||||
<p className="empty-state">
|
||||
{syntax?.accepted === false
|
||||
{retainedSyntax?.accepted === false
|
||||
? "Token parsing is unavailable until the template is within the input limit."
|
||||
: "Enter a replacement template."}
|
||||
</p>
|
||||
)}
|
||||
{syntax?.diagnosticsTruncated ? (
|
||||
{retainedSyntax?.diagnosticsTruncated ? (
|
||||
<p
|
||||
className="render-limit-banner"
|
||||
data-testid="replacement-diagnostic-render-limit"
|
||||
@@ -237,7 +268,8 @@ export function ReplacementPanel({
|
||||
{TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics.toLocaleString()}{" "}
|
||||
entries, including a summary, for{" "}
|
||||
{(
|
||||
syntax.totalDiagnostics ?? syntax.diagnostics.length
|
||||
retainedSyntax.totalDiagnostics ??
|
||||
retainedSyntax.diagnostics.length
|
||||
).toLocaleString()}{" "}
|
||||
findings.
|
||||
</p>
|
||||
@@ -252,7 +284,7 @@ export function ReplacementPanel({
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
Native matches · bounded {engineLabel} substitution
|
||||
Native matches · bounded {resultEngineLabel} substitution
|
||||
</p>
|
||||
<h2>Replacement output</h2>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ const syntaxHarness = vi.hoisted(() => ({
|
||||
|
||||
const engineHarness = vi.hoisted(() => ({
|
||||
requests: [] as RegexExecutionRequest[],
|
||||
emitWholeFirstCharacterMatch: false,
|
||||
}));
|
||||
|
||||
vi.mock("../editors/CodeEditor", () => ({
|
||||
@@ -45,18 +46,35 @@ vi.mock("../editors/CodeEditor", () => ({
|
||||
onChange,
|
||||
label,
|
||||
readOnly,
|
||||
marks,
|
||||
selectedRange,
|
||||
onSelectionChange,
|
||||
}: {
|
||||
readonly value: string;
|
||||
readonly onChange?: (value: string) => void;
|
||||
readonly label: string;
|
||||
readonly readOnly?: boolean;
|
||||
readonly marks?: readonly unknown[];
|
||||
readonly selectedRange?: { readonly startUtf16: number };
|
||||
readonly onSelectionChange?: (range: {
|
||||
readonly startUtf16: number;
|
||||
readonly endUtf16: number;
|
||||
}) => void;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
aria-label={label}
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
data-mark-count={marks?.length ?? 0}
|
||||
data-selected-start={selectedRange?.startUtf16}
|
||||
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,
|
||||
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: [],
|
||||
elapsedMs: 1,
|
||||
truncated: false,
|
||||
@@ -231,6 +260,7 @@ afterEach(() => {
|
||||
syntaxHarness.patterns.length = 0;
|
||||
syntaxHarness.replacements.length = 0;
|
||||
engineHarness.requests.length = 0;
|
||||
engineHarness.emitWholeFirstCharacterMatch = false;
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
render(<Workbench />);
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
} from "../regex/model/match";
|
||||
import type {
|
||||
NormalizedRegexNode,
|
||||
RegexSyntaxRequest,
|
||||
RegexSyntaxResult,
|
||||
ReplacementSyntaxResult,
|
||||
ReplacementToken,
|
||||
@@ -105,6 +106,79 @@ type RunState =
|
||||
| { readonly status: "timeout"; 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(
|
||||
node: NormalizedRegexNode,
|
||||
range: SourceRange,
|
||||
@@ -401,11 +475,9 @@ export function Workbench() {
|
||||
);
|
||||
const [syntaxSnapshot, setSyntaxSnapshot] =
|
||||
useState<PatternSyntaxSnapshot<RegexSyntaxResult>>();
|
||||
const [replacementSyntax, setReplacementSyntax] =
|
||||
useState<ReplacementSyntaxResult>();
|
||||
const [execution, setExecution] = useState<RegexExecutionResult>();
|
||||
const [replacementResult, setReplacementResult] =
|
||||
useState<RegexReplacementResult>();
|
||||
const [replacementSyntaxSnapshot, setReplacementSyntaxSnapshot] =
|
||||
useState<ReplacementSyntaxSnapshot>();
|
||||
const [resultFrame, setResultFrame] = useState<ResultFrame>();
|
||||
const [selectedSyntax, setSelectedSyntax] = useState<NormalizedRegexNode>();
|
||||
const [requestedPatternRange, setRequestedPatternRange] =
|
||||
useState<SourceRange>();
|
||||
@@ -443,6 +515,10 @@ export function Workbench() {
|
||||
const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
|
||||
const [corpusSession, setCorpusSession] = useState(0);
|
||||
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(() => ({
|
||||
id: projectId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -490,9 +566,7 @@ export function Workbench() {
|
||||
return testEngineSupervisor.current;
|
||||
};
|
||||
|
||||
const clearExecutionResults = useCallback(() => {
|
||||
setExecution(undefined);
|
||||
setReplacementResult(undefined);
|
||||
const clearResultSelections = useCallback(() => {
|
||||
setSelectedExtraction(undefined);
|
||||
setSelectedCaptureRow(undefined);
|
||||
setRequestedSubjectRange(undefined);
|
||||
@@ -502,22 +576,24 @@ export function Workbench() {
|
||||
|
||||
const invalidateExecution = (preserveCurrentResult = false) => {
|
||||
executionRevision.current += 1;
|
||||
setExecutionGeneration(executionRevision.current);
|
||||
engineSupervisor.current?.cancel();
|
||||
clearExecutionResults();
|
||||
clearResultSelections();
|
||||
if (!preserveCurrentResult) setCurrentResultDraft(undefined);
|
||||
setRunState({
|
||||
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 = () => {
|
||||
syntaxRevision.current += 1;
|
||||
replacementSyntaxRevision.current += 1;
|
||||
setSyntaxGeneration(syntaxRevision.current);
|
||||
setReplacementSyntaxGeneration(replacementSyntaxRevision.current);
|
||||
patternSyntaxSupervisor.current?.cancel();
|
||||
replacementSyntaxSupervisor.current?.cancel();
|
||||
setSyntaxSnapshot(undefined);
|
||||
setReplacementSyntax(undefined);
|
||||
setSelectedSyntax(undefined);
|
||||
setSelectedReplacementToken(undefined);
|
||||
setSelectedReplacementContribution(undefined);
|
||||
@@ -539,8 +615,8 @@ export function Workbench() {
|
||||
const changeReplacement = (value: string) => {
|
||||
invalidateExecution();
|
||||
replacementSyntaxRevision.current += 1;
|
||||
setReplacementSyntaxGeneration(replacementSyntaxRevision.current);
|
||||
replacementSyntaxSupervisor.current?.cancel();
|
||||
setReplacementSyntax(undefined);
|
||||
setSelectedReplacementToken(undefined);
|
||||
setSelectedReplacementContribution(undefined);
|
||||
setRequestedOutputRange(undefined);
|
||||
@@ -582,16 +658,20 @@ export function Workbench() {
|
||||
options,
|
||||
};
|
||||
}, [flags, flavour, flavourVersion, options, pattern]);
|
||||
const syntax = isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest)
|
||||
const syntax =
|
||||
syntaxSnapshot?.revision === syntaxGeneration &&
|
||||
isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest)
|
||||
? syntaxSnapshot.result
|
||||
: undefined;
|
||||
const presentedSyntax = syntax ?? syntaxSnapshot?.result;
|
||||
const presentedSyntaxIsStale =
|
||||
presentedSyntax !== undefined && syntax === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const currentRevision = syntaxRevision.current;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
|
||||
if (syntaxRevision.current !== currentRevision) return;
|
||||
setSyntaxSnapshot(undefined);
|
||||
setRunState({
|
||||
status: "error",
|
||||
message: `Pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`,
|
||||
@@ -613,7 +693,6 @@ export function Workbench() {
|
||||
: undefined,
|
||||
);
|
||||
if (!result.accepted) {
|
||||
clearExecutionResults();
|
||||
setRunState({
|
||||
status: "error",
|
||||
message:
|
||||
@@ -622,7 +701,6 @@ export function Workbench() {
|
||||
} else if (
|
||||
result.captures.length > DEFAULT_REGEX_LIMITS.maximumCaptureGroups
|
||||
) {
|
||||
clearExecutionResults();
|
||||
setRunState({
|
||||
status: "error",
|
||||
message: captureGroupLimitMessage(result.captures.length),
|
||||
@@ -637,7 +715,6 @@ export function Workbench() {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearExecutionResults();
|
||||
setRunState({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -645,20 +722,30 @@ export function Workbench() {
|
||||
});
|
||||
}, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [clearExecutionResults, pattern, syntaxRequest]);
|
||||
}, [pattern, syntaxRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!syntax) return;
|
||||
const currentRevision = ++replacementSyntaxRevision.current;
|
||||
const currentRevision = replacementSyntaxRevision.current;
|
||||
const patternSyntax = syntax;
|
||||
void replacementSyntaxEngine()
|
||||
.parseReplacement({
|
||||
flavour,
|
||||
replacement,
|
||||
captureMetadata: syntax.captures,
|
||||
captureMetadata: patternSyntax.captures,
|
||||
})
|
||||
.then((result) => {
|
||||
if (replacementSyntaxRevision.current !== currentRevision) return;
|
||||
setReplacementSyntax(result);
|
||||
setReplacementSyntaxSnapshot({
|
||||
revision: currentRevision,
|
||||
flavour,
|
||||
replacement,
|
||||
patternSyntax,
|
||||
result,
|
||||
});
|
||||
setSelectedReplacementToken(undefined);
|
||||
setSelectedReplacementContribution(undefined);
|
||||
setRequestedOutputRange(undefined);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (replacementSyntaxRevision.current !== currentRevision) return;
|
||||
@@ -671,6 +758,22 @@ export function Workbench() {
|
||||
});
|
||||
}, [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 makeRequest = useCallback(
|
||||
@@ -704,7 +807,6 @@ export function Workbench() {
|
||||
window.clearTimeout(liveExecutionTimer.current);
|
||||
liveExecutionTimer.current = undefined;
|
||||
}
|
||||
clearExecutionResults();
|
||||
setCurrentResultDraft(undefined);
|
||||
if (
|
||||
!syntax?.accepted ||
|
||||
@@ -744,6 +846,23 @@ export function Workbench() {
|
||||
return;
|
||||
}
|
||||
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 =
|
||||
kind === "live"
|
||||
? DEFAULT_REGEX_LIMITS.liveExecutionTimeoutMs
|
||||
@@ -766,16 +885,13 @@ export function Workbench() {
|
||||
limit,
|
||||
);
|
||||
if (executionRevision.current !== requestRevision) return;
|
||||
setExecution(result.execution);
|
||||
if (!result.execution.accepted) {
|
||||
setReplacementResult(undefined);
|
||||
setRunState({
|
||||
status: "error",
|
||||
message: engineRejectionMessage(result.execution),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setReplacementResult(result);
|
||||
const commonDraft = {
|
||||
name: "Current replacement result",
|
||||
pattern,
|
||||
@@ -825,11 +941,17 @@ export function Workbench() {
|
||||
: undefined;
|
||||
resultDraftNotice = decision.notice;
|
||||
setCurrentResultDraft(resultDraft);
|
||||
setResultFrame({
|
||||
...frameIdentity,
|
||||
replacementSyntax: currentReplacementSyntax,
|
||||
execution: result.execution,
|
||||
replacementResult: result,
|
||||
});
|
||||
clearResultSelections();
|
||||
elapsedMs = result.execution.elapsedMs;
|
||||
} else {
|
||||
const result = await executionEngine().execute(makeRequest(), limit);
|
||||
if (executionRevision.current !== requestRevision) return;
|
||||
setExecution(result);
|
||||
if (!result.accepted) {
|
||||
setRunState({
|
||||
status: "error",
|
||||
@@ -861,6 +983,11 @@ export function Workbench() {
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setResultFrame({
|
||||
...frameIdentity,
|
||||
execution: result,
|
||||
});
|
||||
clearResultSelections();
|
||||
elapsedMs = result.elapsedMs;
|
||||
}
|
||||
setRunState({
|
||||
@@ -871,7 +998,6 @@ export function Workbench() {
|
||||
});
|
||||
} catch (error) {
|
||||
if (executionRevision.current !== requestRevision) return;
|
||||
clearExecutionResults();
|
||||
const timedOut =
|
||||
error instanceof WorkerRequestError && error.kind === "timeout";
|
||||
setRunState({
|
||||
@@ -886,20 +1012,22 @@ export function Workbench() {
|
||||
},
|
||||
[
|
||||
activeTestConfiguration,
|
||||
clearExecutionResults,
|
||||
clearResultSelections,
|
||||
flags,
|
||||
flavour,
|
||||
flavourVersion,
|
||||
makeRequest,
|
||||
mode,
|
||||
options,
|
||||
pattern,
|
||||
replacement,
|
||||
replacementSyntax?.accepted,
|
||||
replacementSyntax,
|
||||
scanAll,
|
||||
subjectBytes,
|
||||
subject,
|
||||
syntax?.accepted,
|
||||
syntax?.captures.length,
|
||||
subjectBytes,
|
||||
syntax,
|
||||
syntaxSnapshot,
|
||||
syntaxRequest,
|
||||
flags,
|
||||
pattern,
|
||||
tests,
|
||||
timeoutMs,
|
||||
],
|
||||
@@ -943,22 +1071,49 @@ export function Workbench() {
|
||||
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(
|
||||
() => buildExtractionTree(execution?.matches ?? [], syntax?.captures ?? []),
|
||||
[execution?.matches, syntax?.captures],
|
||||
() =>
|
||||
buildExtractionTree(
|
||||
execution?.matches ?? [],
|
||||
resultSyntax?.captures ?? [],
|
||||
),
|
||||
[execution?.matches, resultSyntax?.captures],
|
||||
);
|
||||
const captureRows = useMemo(
|
||||
() => buildCaptureRows(execution?.matches ?? [], subject),
|
||||
[execution?.matches, subject],
|
||||
() => buildCaptureRows(execution?.matches ?? [], resultSubject),
|
||||
[execution?.matches, resultSubject],
|
||||
);
|
||||
const repeatedCaptures = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
syntax?.captures
|
||||
resultSyntax?.captures
|
||||
.filter((capture) => capture.repeated)
|
||||
.map((capture) => capture.number) ?? [],
|
||||
),
|
||||
[syntax?.captures],
|
||||
[resultSyntax?.captures],
|
||||
);
|
||||
const selectedSubjectRange = useMemo(
|
||||
() =>
|
||||
@@ -971,8 +1126,12 @@ export function Workbench() {
|
||||
[selectedCaptureRow, selectedExtraction?.range],
|
||||
);
|
||||
const subjectMarkSummary = useMemo(
|
||||
() => subjectMarks(execution, selectedSubjectRange),
|
||||
[execution, selectedSubjectRange],
|
||||
() =>
|
||||
subjectMarks(
|
||||
resultFrameIsCurrent ? execution : undefined,
|
||||
resultFrameIsCurrent ? selectedSubjectRange : undefined,
|
||||
),
|
||||
[execution, resultFrameIsCurrent, selectedSubjectRange],
|
||||
);
|
||||
const patternMarkSummary = useMemo(
|
||||
() => syntaxMarks(syntax, selectedSyntax),
|
||||
@@ -980,15 +1139,19 @@ export function Workbench() {
|
||||
);
|
||||
const replacementPreview = useMemo(
|
||||
() =>
|
||||
replacementSyntax?.accepted === true && replacementResult
|
||||
? buildReplacementPreview(replacementSyntax, replacementResult, subject)
|
||||
resultFrame?.replacementSyntax?.accepted === true && replacementResult
|
||||
? buildReplacementPreview(
|
||||
resultFrame.replacementSyntax,
|
||||
replacementResult,
|
||||
resultSubject,
|
||||
)
|
||||
: undefined,
|
||||
[replacementResult, replacementSyntax, subject],
|
||||
[replacementResult, resultFrame, resultSubject],
|
||||
);
|
||||
const diagnostics = [
|
||||
...(syntax?.diagnostics ?? []),
|
||||
...(replacementSyntax?.diagnostics ?? []),
|
||||
...(execution?.diagnostics ?? []),
|
||||
...(resultFrameIsCurrent ? (execution?.diagnostics ?? []) : []),
|
||||
];
|
||||
const currentProject: RegexProjectV1 = {
|
||||
schemaVersion: 1,
|
||||
@@ -1029,14 +1192,14 @@ export function Workbench() {
|
||||
);
|
||||
setSelectedCaptureRow(captureRow);
|
||||
setRequestedSubjectRange(
|
||||
requestEditorSelection && node.range
|
||||
resultFrameIsCurrent && requestEditorSelection && node.range
|
||||
? {
|
||||
startUtf16: node.range.startUtf16,
|
||||
endUtf16: node.range.endUtf16,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (node.groupNumber !== undefined) {
|
||||
if (resultFrameIsCurrent && node.groupNumber !== undefined) {
|
||||
const definition = syntax?.captures.find(
|
||||
(capture) => capture.number === node.groupNumber,
|
||||
);
|
||||
@@ -1052,16 +1215,29 @@ export function Workbench() {
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
} else if (resultFrameIsCurrent) {
|
||||
setSelectedSyntax(syntax?.root);
|
||||
setRequestedPatternRange(syntax?.root.range);
|
||||
} else {
|
||||
setSelectedSyntax(undefined);
|
||||
setRequestedPatternRange(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const selectSyntaxNode = (
|
||||
node: NormalizedRegexNode,
|
||||
requestEditorSelection = true,
|
||||
mapsToCurrentPattern = true,
|
||||
) => {
|
||||
if (!mapsToCurrentPattern) {
|
||||
setSelectedSyntax(undefined);
|
||||
setRequestedPatternRange(undefined);
|
||||
setSelectedExtraction(undefined);
|
||||
setSelectedCaptureRow(undefined);
|
||||
setRequestedSubjectRange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSyntax(node);
|
||||
setRequestedPatternRange(
|
||||
requestEditorSelection
|
||||
@@ -1072,6 +1248,13 @@ export function Workbench() {
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!resultFrameIsCurrent) {
|
||||
setSelectedExtraction(undefined);
|
||||
setSelectedCaptureRow(undefined);
|
||||
setRequestedSubjectRange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const enclosingCapture = syntax?.captures
|
||||
.filter(
|
||||
(capture) =>
|
||||
@@ -1129,7 +1312,9 @@ export function Workbench() {
|
||||
contribution: ReplacementContributionPreview,
|
||||
) => {
|
||||
setSelectedReplacementContribution(contribution);
|
||||
setSelectedReplacementToken(contribution.token);
|
||||
setSelectedReplacementToken(
|
||||
resultFrameIsCurrent ? contribution.token : undefined,
|
||||
);
|
||||
setRequestedOutputRange(contribution.outputRange);
|
||||
|
||||
const match = execution?.matches.find(
|
||||
@@ -1163,7 +1348,7 @@ export function Workbench() {
|
||||
} else {
|
||||
setSelectedExtraction(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);
|
||||
invalidatePatternSyntax();
|
||||
executionRevision.current += 1;
|
||||
setExecutionGeneration(executionRevision.current);
|
||||
engineSupervisor.current?.cancel();
|
||||
cancelTestRun();
|
||||
setFlavour(importedFlavour.id);
|
||||
@@ -1442,7 +1628,10 @@ export function Workbench() {
|
||||
setTestResults(new Map());
|
||||
setLive(false);
|
||||
setScanAll(project.ui?.scanAll ?? false);
|
||||
clearExecutionResults();
|
||||
setResultFrame(undefined);
|
||||
setSyntaxSnapshot(undefined);
|
||||
setReplacementSyntaxSnapshot(undefined);
|
||||
clearResultSelections();
|
||||
setSelectedSyntax(undefined);
|
||||
setRequestedPatternRange(undefined);
|
||||
setSelectedReplacementToken(undefined);
|
||||
@@ -1973,7 +2162,7 @@ export function Workbench() {
|
||||
>
|
||||
<CapabilityPanel
|
||||
syntax={syntax}
|
||||
execution={execution}
|
||||
execution={resultFrameIsCurrent ? execution : undefined}
|
||||
onClose={() => setShowCapabilities(false)}
|
||||
/>
|
||||
</ModalDialog>
|
||||
@@ -2041,10 +2230,17 @@ export function Workbench() {
|
||||
<span>{runState.status}</span>
|
||||
<strong>{runState.message}</strong>
|
||||
{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.elapsedMs.toFixed(2)} ms · native offsets{" "}
|
||||
{execution.engine.offsetUnit}
|
||||
{resultFrameIsStale
|
||||
? " · bound to the last completed inputs; no ranges are applied to current editors"
|
||||
: ""}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -2097,10 +2293,17 @@ export function Workbench() {
|
||||
/>
|
||||
</section>
|
||||
<ExplanationTree
|
||||
root={syntax?.root}
|
||||
root={presentedSyntax?.root}
|
||||
selectedId={selectedSyntax?.id}
|
||||
flags={flags}
|
||||
onSelect={(node) => selectSyntaxNode(node)}
|
||||
flags={
|
||||
presentedSyntaxIsStale
|
||||
? (syntaxSnapshot?.request.flags ?? [])
|
||||
: flags
|
||||
}
|
||||
stale={presentedSyntaxIsStale}
|
||||
onSelect={(node) =>
|
||||
selectSyntaxNode(node, true, !presentedSyntaxIsStale)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{mode !== "tests" && mode !== "corpus" ? (
|
||||
@@ -2146,6 +2349,11 @@ export function Workbench() {
|
||||
onChange={changeSubject}
|
||||
onSelectionChange={(range) => {
|
||||
setRequestedSubjectRange(undefined);
|
||||
if (!resultFrameIsCurrent) {
|
||||
setSelectedExtraction(undefined);
|
||||
setSelectedCaptureRow(undefined);
|
||||
return;
|
||||
}
|
||||
const node = findSmallestExtractionNode(extraction, range);
|
||||
if (node) {
|
||||
selectExtraction(node, false);
|
||||
@@ -2180,7 +2388,7 @@ export function Workbench() {
|
||||
{mode === "match" ? (
|
||||
<CaptureTable
|
||||
rows={captureRows}
|
||||
subject={subject}
|
||||
subject={resultSubject}
|
||||
sourceResultTruncated={execution?.truncated ?? false}
|
||||
selectedId={selectedCaptureRow?.id}
|
||||
onSelect={(row) => {
|
||||
@@ -2194,7 +2402,7 @@ export function Workbench() {
|
||||
setSelectedCaptureRow(row);
|
||||
setSelectedExtraction(undefined);
|
||||
setRequestedSubjectRange(
|
||||
row.start === undefined
|
||||
!resultFrameIsCurrent || row.start === undefined
|
||||
? undefined
|
||||
: {
|
||||
startUtf16: row.start,
|
||||
@@ -2202,9 +2410,11 @@ export function Workbench() {
|
||||
},
|
||||
);
|
||||
}
|
||||
const definition = syntax?.captures.find(
|
||||
const definition = resultFrameIsCurrent
|
||||
? syntax?.captures.find(
|
||||
(capture) => capture.number === row.groupNumber,
|
||||
);
|
||||
)
|
||||
: undefined;
|
||||
if (definition && syntax) {
|
||||
const selected = findSmallestNode(syntax.root, definition.range);
|
||||
setSelectedSyntax(selected);
|
||||
@@ -2226,7 +2436,11 @@ export function Workbench() {
|
||||
replacement={replacement}
|
||||
onReplacementChange={changeReplacement}
|
||||
syntax={replacementSyntax}
|
||||
presentedSyntax={presentedReplacementSyntax}
|
||||
syntaxStale={presentedReplacementSyntaxIsStale}
|
||||
presentedFlavour={presentedReplacementFlavour}
|
||||
result={replacementResult}
|
||||
resultFlavour={resultFrame?.flavour}
|
||||
preview={replacementPreview}
|
||||
selectedToken={selectedReplacementToken}
|
||||
selectedContributionId={selectedReplacementContribution?.id}
|
||||
@@ -2244,7 +2458,7 @@ export function Workbench() {
|
||||
templateSource={listTemplate}
|
||||
onTemplateChange={setListTemplate}
|
||||
matches={execution?.matches ?? []}
|
||||
subject={subject}
|
||||
subject={resultSubject}
|
||||
sourceResultTruncated={execution?.truncated ?? false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -161,6 +161,24 @@ textarea:focus-visible,
|
||||
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,
|
||||
.secondary-button,
|
||||
.icon-button {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.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.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
|
||||
@@ -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 REGEXPP_VERSION = "4.12.2";
|
||||
|
||||
@@ -446,6 +446,7 @@ test("rapid pattern and replacement edits cannot run with stale syntax", async (
|
||||
await page.goto("./");
|
||||
await waitForReady(page);
|
||||
await page.getByLabel("Live").uncheck();
|
||||
const initialExtractionCount = await page.locator(".extraction-row").count();
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
|
||||
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 runButton.evaluate((button: HTMLButtonElement) => button.click());
|
||||
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();
|
||||
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]");
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
await page.goto("./");
|
||||
await waitForReady(page);
|
||||
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 expect(page.locator(".run-status")).toHaveClass(/status-idle/u);
|
||||
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.locator(".extraction-row")).toHaveCount(0);
|
||||
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0);
|
||||
await expect(page.getByTestId("stale-result-frame")).toContainText(
|
||||
"no ranges are applied to current editors",
|
||||
);
|
||||
await expect(extractionRows).toHaveCount(initialExtractionCount);
|
||||
await expect(captureRows).toHaveCount(initialCaptureCount);
|
||||
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 waitForReady(page);
|
||||
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(
|
||||
page,
|
||||
@@ -520,8 +537,14 @@ test("capture-group resource limits block execution visibly", async ({
|
||||
await expect(page.locator(".run-status")).toContainText(
|
||||
"above the 1,000 group execution limit",
|
||||
);
|
||||
await expect(page.locator(".run-status small")).toHaveCount(0);
|
||||
await expect(page.locator(".extraction-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
|
||||
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 ({
|
||||
@@ -779,6 +802,25 @@ test("replacement and list modes use native and typed templates", async ({
|
||||
page.getByTestId("editor-replacement-output").locator(".cm-selected-range"),
|
||||
).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 expect(
|
||||
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 waitForReady(page);
|
||||
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 setEditor(page, "editor-regular-expression-pattern", "^(a|aa)+$");
|
||||
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(
|
||||
"worker was terminated",
|
||||
);
|
||||
await expect(page.locator(".run-status small")).toHaveCount(0);
|
||||
await expect(page.locator(".extraction-row")).toHaveCount(0);
|
||||
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0);
|
||||
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
|
||||
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);
|
||||
|
||||
await setEditor(page, "editor-regular-expression-pattern", "^ok$");
|
||||
|
||||
Reference in New Issue
Block a user