Files
regex-tools/src/components/MinimizePanel.tsx

871 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from "react";
import { writeClipboardText } from "../browser/clipboard";
import {
AVAILABLE_REGEX_FLAVOURS,
defaultRegexOptions,
} from "../regex/flavours/flavour-registry";
import {
DEFAULT_REGEX_LIMITS,
utf8ByteLength,
} from "../regex/execution/request-limits";
import type {
RegexEngineOptions,
RegexFlavourId,
} from "../regex/model/flavour";
import type { RegexTestExpectation } from "../regex/tests/test-case.types";
import { SubjectMinimizer } from "../regex/minimization/SubjectMinimizer";
import type {
EngineFailureOracle,
SubjectMinimizationProgress,
SubjectMinimizationRequest,
SubjectMinimizationResult,
} from "../regex/minimization/minimization.types";
import "./MinimizePanel.css";
type MinimizationMode =
| "unit-test-failure"
| "capture-range-mismatch"
| "engine-timeout"
| "comparison-mismatch";
type UnitExpectationKind = RegexTestExpectation["kind"];
export interface SubjectMinimizerClient {
minimize(
request: SubjectMinimizationRequest,
onProgress?: (progress: SubjectMinimizationProgress) => void,
): Promise<SubjectMinimizationResult>;
cancel(): void;
dispose(): void;
}
export interface MinimizePanelProps {
readonly activeFlavour: RegexFlavourId;
readonly activeFlavourVersion: string;
readonly activeFlags: readonly string[];
readonly activeOptions: RegexEngineOptions;
readonly pattern: string;
readonly subject: string;
readonly replacement: string;
readonly scanAll: boolean;
readonly timeoutMs: number;
readonly onUseSubject: (subject: string) => void;
readonly onClose: () => void;
readonly createMinimizer?: () => SubjectMinimizerClient;
}
const ECMASCRIPT = AVAILABLE_REGEX_FLAVOURS.require("ecmascript");
const PCRE2 = AVAILABLE_REGEX_FLAVOURS.require("pcre2");
function flagsFor(
activeFlavour: RegexFlavourId,
activeFlags: readonly string[],
flavour: "ecmascript" | "pcre2",
): readonly string[] {
const definition = flavour === "ecmascript" ? ECMASCRIPT : PCRE2;
return activeFlavour === flavour
? definition.flags
.map((flag) => flag.value)
.filter((flag) => activeFlags.includes(flag))
: definition.defaultFlags;
}
function optionsFor(
activeFlavour: RegexFlavourId,
activeOptions: RegexEngineOptions,
flavour: "ecmascript" | "pcre2",
): RegexEngineOptions {
if (activeFlavour === flavour) return activeOptions;
return defaultRegexOptions(flavour === "ecmascript" ? ECMASCRIPT : PCRE2);
}
function parseCaptureSelector(value: string): {
readonly groupNumber?: number;
readonly groupName?: string;
} {
const trimmed = value.trim();
const number = Number(trimmed);
return trimmed !== "" && Number.isSafeInteger(number) && number > 0
? { groupNumber: number }
: { groupName: trimmed };
}
function resultLabel(result: SubjectMinimizationResult): string {
if (result.locallyMinimal) {
return "Locally minimal under the documented transforms";
}
return result.stopReason.replaceAll("-", " ");
}
function Observation({
label,
observation,
}: {
readonly label: string;
readonly observation: SubjectMinimizationResult["baseline"];
}) {
return (
<article className={`minimizer-observation is-${observation.status}`}>
<strong>{label}</strong>
<span>
{observation.status} ·{" "}
{observation.reproduced ? "predicate reproduced" : "not reproduced"}
</span>
<p>{observation.summary}</p>
{observation.fingerprint.length > 0 ? (
<code>{observation.fingerprint.join(" · ")}</code>
) : null}
{observation.sides?.length ? (
<small>
{observation.sides
.map(
(side) =>
`${side.flavour}: ${side.status}${
side.engineIdentity ? ` · ${side.engineIdentity}` : ""
}`,
)
.join(" | ")}
</small>
) : null}
</article>
);
}
export function MinimizePanel({
activeFlavour,
activeFlavourVersion,
activeFlags,
activeOptions,
pattern: initialPattern,
subject: initialSubject,
replacement: initialReplacement,
scanAll: initialScanAll,
timeoutMs: initialTimeoutMs,
onUseSubject,
onClose,
createMinimizer = () => new SubjectMinimizer(),
}: MinimizePanelProps) {
const [mode, setMode] = useState<MinimizationMode>("unit-test-failure");
const [subject, setSubject] = useState(initialSubject);
const [scanAll, setScanAll] = useState(initialScanAll);
const [unitKind, setUnitKind] =
useState<UnitExpectationKind>("should-not-match");
const [expectedCount, setExpectedCount] = useState(1);
const [matchIndex, setMatchIndex] = useState(0);
const [expectedValue, setExpectedValue] = useState("");
const [captureSelector, setCaptureSelector] = useState("1");
const [captureStatus, setCaptureStatus] = useState<
"participated" | "did-not-participate" | "matched-empty"
>("participated");
const [checkCaptureValue, setCheckCaptureValue] = useState(false);
const [expectedStart, setExpectedStart] = useState(0);
const [expectedEnd, setExpectedEnd] = useState(0);
const [durationLimitMs, setDurationLimitMs] = useState(10);
const [comparisonOperation, setComparisonOperation] = useState<
"match" | "replace"
>("match");
const [ecmaPattern, setEcmaPattern] = useState(initialPattern);
const [pcrePattern, setPcrePattern] = useState(initialPattern);
const [ecmaReplacement, setEcmaReplacement] = useState(initialReplacement);
const [pcreReplacement, setPcreReplacement] = useState(initialReplacement);
const [maximumEvaluations, setMaximumEvaluations] = useState(500);
const [maximumWallTimeMs, setMaximumWallTimeMs] = useState(15_000);
const [candidateTimeoutMs, setCandidateTimeoutMs] = useState(
Math.min(
DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs,
Math.max(1, initialTimeoutMs),
),
);
const [running, setRunning] = useState(false);
const [status, setStatus] = useState(
"Select an exact failure predicate, then verify the baseline.",
);
const [progress, setProgress] = useState<SubjectMinimizationProgress>();
const [result, setResult] = useState<SubjectMinimizationResult>();
const [copyStatus, setCopyStatus] = useState("");
const minimizer = useRef<SubjectMinimizerClient | null>(null);
const revision = useRef(0);
useEffect(
() => () => {
revision.current += 1;
minimizer.current?.dispose();
minimizer.current = null;
},
[],
);
const ecmaFlags = useMemo(
() => flagsFor(activeFlavour, activeFlags, "ecmascript"),
[activeFlags, activeFlavour],
);
const pcreFlags = useMemo(
() => flagsFor(activeFlavour, activeFlags, "pcre2"),
[activeFlags, activeFlavour],
);
const ecmaOptions = useMemo(
() => optionsFor(activeFlavour, activeOptions, "ecmascript"),
[activeFlavour, activeOptions],
);
const pcreOptions = useMemo(
() => optionsFor(activeFlavour, activeOptions, "pcre2"),
[activeFlavour, activeOptions],
);
const activeEngineSupported =
activeFlavour === "ecmascript" || activeFlavour === "pcre2";
const unitExpectation = (): RegexTestExpectation => {
switch (unitKind) {
case "should-match":
case "should-not-match":
case "must-time-out":
return { kind: unitKind };
case "match-count":
return { kind: unitKind, count: expectedCount };
case "full-match":
return {
kind: unitKind,
matchIndex,
value: expectedValue,
};
case "capture":
return {
kind: unitKind,
matchIndex,
...parseCaptureSelector(captureSelector),
status: captureStatus,
...(checkCaptureValue ? { value: expectedValue } : {}),
};
case "replacement":
return { kind: unitKind, expected: expectedValue };
case "must-complete-within":
return { kind: unitKind, milliseconds: durationLimitMs };
}
};
const activeSide = {
flavour:
activeFlavour === "pcre2" ? ("pcre2" as const) : ("ecmascript" as const),
flavourVersion: activeEngineSupported
? activeFlavourVersion
: ECMASCRIPT.defaultVersion,
pattern: initialPattern,
flags: activeEngineSupported ? activeFlags : ECMASCRIPT.defaultFlags,
options: activeEngineSupported
? activeOptions
: defaultRegexOptions(ECMASCRIPT),
...(unitKind === "replacement" ? { replacement: initialReplacement } : {}),
};
const request = (): SubjectMinimizationRequest => {
let target: SubjectMinimizationRequest["target"];
if (mode === "comparison-mismatch") {
target = {
kind: "comparison-mismatch",
operation: comparisonOperation,
scanAll,
sides: [
{
flavour: "ecmascript",
flavourVersion: ECMASCRIPT.defaultVersion,
pattern: ecmaPattern,
flags: ecmaFlags,
options: ecmaOptions,
...(comparisonOperation === "replace"
? { replacement: ecmaReplacement }
: {}),
},
{
flavour: "pcre2",
flavourVersion: PCRE2.defaultVersion,
pattern: pcrePattern,
flags: pcreFlags,
options: pcreOptions,
...(comparisonOperation === "replace"
? { replacement: pcreReplacement }
: {}),
},
],
};
} else {
let oracle: EngineFailureOracle;
if (mode === "engine-timeout") {
oracle = { kind: "engine-timeout" };
} else if (mode === "capture-range-mismatch") {
oracle = {
kind: "capture-range-mismatch",
matchIndex,
...parseCaptureSelector(captureSelector),
expectedRange: {
startUtf16: expectedStart,
endUtf16: expectedEnd,
},
};
} else {
oracle = {
kind: "unit-test-failure",
expectation: unitExpectation(),
};
}
target = {
kind: "engine",
side: activeSide,
scanAll,
oracle,
};
}
return {
schemaVersion: 1,
subject,
target,
budgets: {
maximumEvaluations,
maximumWallTimeMs,
candidateTimeoutMs,
},
maximumMatches: 1_000,
maximumCaptureRows: 10_000,
maximumOutputBytes: 1024 * 1024,
};
};
const run = async () => {
if (mode !== "comparison-mismatch" && !activeEngineSupported) {
setStatus(
`Subject minimization is not yet implemented for ${activeFlavour}; select ECMAScript or PCRE2, or use the explicit ECMAScript ↔ PCRE2 comparison target.`,
);
return;
}
const currentRevision = ++revision.current;
setRunning(true);
setResult(undefined);
setCopyStatus("");
setStatus("Verifying the baseline with the selected real engine worker…");
try {
minimizer.current ??= createMinimizer();
const minimized = await minimizer.current.minimize(request(), (value) => {
if (currentRevision !== revision.current) return;
setProgress(value);
setStatus(
`${value.phase.replaceAll("-", " ")} · ${value.evaluations}/${value.maximumEvaluations} evaluations · ${value.acceptedReductions} accepted`,
);
});
if (currentRevision !== revision.current) return;
setResult(minimized);
setStatus(resultLabel(minimized));
} catch (error) {
if (currentRevision !== revision.current) return;
setStatus(error instanceof Error ? error.message : String(error));
} finally {
if (currentRevision === revision.current) setRunning(false);
}
};
const cancel = () => {
revision.current += 1;
minimizer.current?.cancel();
setRunning(false);
setStatus("Cancelled; active syntax and engine workers were terminated.");
};
return (
<section
className="panel minimize-panel"
aria-labelledby="minimize-heading"
>
<header className="panel-heading">
<div>
<p className="eyebrow">Bounded failing-subject reduction</p>
<h2 id="minimize-heading">Minimize a reproducer</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close subject minimizer"
data-dialog-initial-focus
onClick={onClose}
>
×
</button>
</header>
<div className="minimizer-body">
<p className="minimizer-advisory">
This reducer proves only local minimality under deterministic
Unicode-scalar chunk deletion, single-scalar deletion and canonical
replacement. It does not claim a globally or semantically minimal
example.
</p>
{!activeEngineSupported ? (
<p className="minimizer-advisory" role="status">
Python and Java execution remain available in the main workbench,
corpus and unit-test modes. This reducer currently has verified
failure oracles only for ECMAScript and PCRE2.
</p>
) : null}
<section className="minimizer-config" aria-labelledby="minimize-target">
<header>
<h3 id="minimize-target">Exact predicate</h3>
<span>
{activeFlavour} · {utf8ByteLength(subject).toLocaleString()} bytes
</span>
</header>
<div className="minimizer-control-grid">
<label>
<span>Failure kind</span>
<select
aria-label="Minimization failure kind"
value={mode}
disabled={running}
onChange={(event) =>
setMode(event.target.value as MinimizationMode)
}
>
<option value="unit-test-failure">
Failing unit-test assertion
</option>
<option value="capture-range-mismatch">
Wrong capture range
</option>
<option value="engine-timeout">Exact engine timeout</option>
<option value="comparison-mismatch">
ECMAScript PCRE2 mismatch
</option>
</select>
</label>
<label className="check-control minimizer-check">
<input
type="checkbox"
checked={scanAll}
disabled={running}
onChange={(event) => setScanAll(event.target.checked)}
/>
Scan all
</label>
</div>
{mode === "unit-test-failure" ? (
<div className="minimizer-control-grid">
<label>
<span>Saved assertion</span>
<select
aria-label="Unit-test expectation"
value={unitKind}
disabled={running}
onChange={(event) =>
setUnitKind(event.target.value as UnitExpectationKind)
}
>
<option value="should-match">Should match</option>
<option value="should-not-match">Should not match</option>
<option value="match-count">Exact match count</option>
<option value="full-match">Full-match value</option>
<option value="capture">Capture status/value</option>
<option value="replacement">Replacement output</option>
<option value="must-complete-within">
Must complete within
</option>
<option value="must-time-out">Must time out</option>
</select>
</label>
{unitKind === "match-count" ? (
<label>
<span>Expected count</span>
<input
type="number"
value={expectedCount}
min={0}
max={DEFAULT_REGEX_LIMITS.maximumMatches}
onChange={(event) =>
setExpectedCount(Number(event.target.value))
}
/>
</label>
) : null}
{unitKind === "full-match" || unitKind === "capture" ? (
<label>
<span>Zero-based match index</span>
<input
type="number"
value={matchIndex}
min={0}
onChange={(event) =>
setMatchIndex(Number(event.target.value))
}
/>
</label>
) : null}
{unitKind === "capture" ? (
<>
<label>
<span>Capture number or name</span>
<input
type="text"
value={captureSelector}
onChange={(event) =>
setCaptureSelector(event.target.value)
}
/>
</label>
<label>
<span>Expected participation</span>
<select
value={captureStatus}
onChange={(event) =>
setCaptureStatus(
event.target.value as typeof captureStatus,
)
}
>
<option value="participated">Participated</option>
<option value="did-not-participate">
Did not participate
</option>
<option value="matched-empty">Matched empty</option>
</select>
</label>
<label className="check-control minimizer-check">
<input
type="checkbox"
checked={checkCaptureValue}
onChange={(event) =>
setCheckCaptureValue(event.target.checked)
}
/>
Assert value too
</label>
</>
) : null}
{unitKind === "full-match" ||
unitKind === "replacement" ||
(unitKind === "capture" && checkCaptureValue) ? (
<label className="minimizer-wide-control">
<span>Expected value</span>
<input
type="text"
value={expectedValue}
onChange={(event) => setExpectedValue(event.target.value)}
/>
</label>
) : null}
{unitKind === "must-complete-within" ? (
<label>
<span>Assertion milliseconds</span>
<input
type="number"
value={durationLimitMs}
min={1}
onChange={(event) =>
setDurationLimitMs(Number(event.target.value))
}
/>
</label>
) : null}
</div>
) : null}
{mode === "capture-range-mismatch" ? (
<div className="minimizer-control-grid">
<label>
<span>Zero-based match index</span>
<input
type="number"
value={matchIndex}
min={0}
onChange={(event) =>
setMatchIndex(Number(event.target.value))
}
/>
</label>
<label>
<span>Capture number or name</span>
<input
type="text"
value={captureSelector}
onChange={(event) => setCaptureSelector(event.target.value)}
/>
</label>
<label>
<span>Expected UTF-16 start</span>
<input
type="number"
value={expectedStart}
min={0}
max={subject.length}
onChange={(event) =>
setExpectedStart(Number(event.target.value))
}
/>
</label>
<label>
<span>Expected UTF-16 end</span>
<input
type="number"
value={expectedEnd}
min={0}
max={subject.length}
onChange={(event) =>
setExpectedEnd(Number(event.target.value))
}
/>
</label>
</div>
) : null}
{mode === "comparison-mismatch" ? (
<>
<div className="minimizer-control-grid">
<label>
<span>Operation</span>
<select
value={comparisonOperation}
onChange={(event) =>
setComparisonOperation(
event.target.value as "match" | "replace",
)
}
>
<option value="match">Match</option>
<option value="replace">Replace</option>
</select>
</label>
</div>
<div className="minimizer-variant-grid">
<label>
<span>ECMAScript pattern</span>
<textarea
value={ecmaPattern}
onChange={(event) => setEcmaPattern(event.target.value)}
/>
</label>
<label>
<span>PCRE2 pattern</span>
<textarea
value={pcrePattern}
onChange={(event) => setPcrePattern(event.target.value)}
/>
</label>
{comparisonOperation === "replace" ? (
<>
<label>
<span>ECMAScript replacement</span>
<textarea
value={ecmaReplacement}
onChange={(event) =>
setEcmaReplacement(event.target.value)
}
/>
</label>
<label>
<span>PCRE2 replacement</span>
<textarea
value={pcreReplacement}
onChange={(event) =>
setPcreReplacement(event.target.value)
}
/>
</label>
</>
) : null}
</div>
</>
) : null}
<label className="minimizer-subject">
<span>Failing subject · local text only</span>
<textarea
aria-label="Subject to minimize"
value={subject}
disabled={running}
onChange={(event) => setSubject(event.target.value)}
/>
</label>
</section>
<section className="minimizer-config" aria-labelledby="minimize-bounds">
<header>
<h3 id="minimize-bounds">Hard bounds</h3>
<span>Fixed deadline per candidate</span>
</header>
<div className="minimizer-control-grid">
<label>
<span>Candidate evaluations</span>
<input
type="number"
value={maximumEvaluations}
min={1}
max={DEFAULT_REGEX_LIMITS.maximumMinimizerRuns}
disabled={running}
onChange={(event) =>
setMaximumEvaluations(Number(event.target.value))
}
/>
</label>
<label>
<span>Aggregate wall time ms</span>
<input
type="number"
value={maximumWallTimeMs}
min={1}
max={60_000}
disabled={running}
onChange={(event) =>
setMaximumWallTimeMs(Number(event.target.value))
}
/>
</label>
<label>
<span>Each candidate ms</span>
<input
type="number"
value={candidateTimeoutMs}
min={1}
max={DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs}
disabled={running}
onChange={(event) =>
setCandidateTimeoutMs(Number(event.target.value))
}
/>
</label>
</div>
</section>
<div className="minimizer-actions">
<button
type="button"
className="primary-button"
disabled={
running ||
(mode !== "comparison-mismatch" && !activeEngineSupported)
}
onClick={() => void run()}
>
{running ? "Minimizing…" : "Verify & minimize"}
</button>
{running ? (
<button type="button" className="secondary-button" onClick={cancel}>
Cancel
</button>
) : null}
<p className="minimizer-status" aria-live="polite">
{status}
</p>
</div>
{progress ? (
<div className="minimizer-progress">
<progress
aria-label="Minimizer evaluation progress"
value={progress.evaluations}
max={progress.maximumEvaluations}
/>
<span>
{progress.currentScalars.toLocaleString()} scalars ·{" "}
{progress.currentUtf8Bytes.toLocaleString()} bytes ·{" "}
{progress.elapsedMs.toFixed(0)}/
{progress.maximumWallTimeMs.toLocaleString()} ms
</span>
</div>
) : null}
{result ? (
<section
className="minimizer-result"
aria-labelledby="minimize-result"
>
<header>
<div>
<p className="eyebrow">Exact target {result.targetIdentity}</p>
<h3 id="minimize-result">{resultLabel(result)}</h3>
</div>
<span className={`minimizer-result-badge is-${result.status}`}>
{result.status}
</span>
</header>
<dl>
<div>
<dt>Scalars</dt>
<dd>
{result.originalScalars.toLocaleString()} {" "}
{result.minimizedScalars.toLocaleString()}
</dd>
</div>
<div>
<dt>UTF-8 bytes</dt>
<dd>
{result.originalUtf8Bytes.toLocaleString()} {" "}
{result.minimizedUtf8Bytes.toLocaleString()}
</dd>
</div>
<div>
<dt>Evaluations</dt>
<dd>
{result.evaluations.toLocaleString()} /{" "}
{result.budgets.maximumEvaluations.toLocaleString()}
</dd>
</div>
<div>
<dt>Accepted reductions</dt>
<dd>{result.acceptedReductions.toLocaleString()}</dd>
</div>
<div>
<dt>Wall time</dt>
<dd>{result.elapsedMs.toFixed(1)} ms</dd>
</div>
<div>
<dt>Stop reason</dt>
<dd>{result.stopReason.replaceAll("-", " ")}</dd>
</div>
</dl>
<div className="minimizer-observation-grid">
<Observation
label="Baseline verification"
observation={result.baseline}
/>
<Observation
label="Final accepted candidate"
observation={result.final}
/>
</div>
<label className="minimizer-output">
<span>Minimized subject</span>
<textarea
aria-label="Minimized subject"
readOnly
value={result.minimizedSubject}
/>
</label>
<div className="minimizer-actions">
<button
type="button"
className="primary-button"
disabled={!result.final.reproduced}
onClick={() => onUseSubject(result.minimizedSubject)}
>
Use minimized subject
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
void writeClipboardText(result.minimizedSubject)
.then(() => setCopyStatus("Copied locally."))
.catch((error: unknown) =>
setCopyStatus(
error instanceof Error ? error.message : String(error),
),
);
}}
>
Copy
</button>
<p aria-live="polite">{copyStatus}</p>
</div>
</section>
) : null}
</div>
</section>
);
}