618 lines
20 KiB
TypeScript
618 lines
20 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import type {
|
||
RegexEngineOptions,
|
||
RegexFlavourId,
|
||
} from "../regex/model/flavour";
|
||
import type { RegexSyntaxResult } from "../regex/model/syntax";
|
||
import type { RegexTestCase } from "../regex/tests/test-case.types";
|
||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||
import { formatEcmaScriptPattern } from "../regex/formatting/ecmascript-format";
|
||
import { PatternFormatValidator } from "../regex/formatting/PatternFormatValidator";
|
||
import type {
|
||
PatternFormatPreview,
|
||
PatternFormatSnapshotCheck,
|
||
PatternFormatValidationInput,
|
||
PatternFormatValidationResult,
|
||
} from "../regex/formatting/formatting.types";
|
||
import "./PatternFormatterPanel.css";
|
||
|
||
const MAXIMUM_VISIBLE_CHANGES = 200;
|
||
const MAXIMUM_VISIBLE_CHECKS = 200;
|
||
|
||
export interface PatternFormatValidatorClient {
|
||
validate(
|
||
input: PatternFormatValidationInput,
|
||
onProgress?: (completed: number, total: number) => void,
|
||
): Promise<PatternFormatValidationResult>;
|
||
cancel(): void;
|
||
dispose(): void;
|
||
}
|
||
|
||
export interface PatternFormatterPanelProps {
|
||
readonly flavour: RegexFlavourId;
|
||
readonly flavourVersion?: string;
|
||
readonly pattern: string;
|
||
readonly flags: readonly string[];
|
||
readonly options: RegexEngineOptions;
|
||
readonly syntax?: RegexSyntaxResult;
|
||
readonly subject: string;
|
||
readonly replacement: string;
|
||
readonly scanAll: boolean;
|
||
readonly timeoutMs: number;
|
||
readonly tests: readonly RegexTestCase[];
|
||
readonly onApply: (formattedPattern: string) => void;
|
||
readonly onClose?: () => void;
|
||
readonly createValidator?: () => PatternFormatValidatorClient;
|
||
}
|
||
|
||
type PreviewState =
|
||
| { readonly status: "available"; readonly preview: PatternFormatPreview }
|
||
| { readonly status: "unavailable"; readonly reason: string };
|
||
|
||
interface ValidationConfiguration {
|
||
readonly flavourVersion?: string;
|
||
readonly preview: PatternFormatPreview;
|
||
readonly flags: readonly string[];
|
||
readonly options: RegexEngineOptions;
|
||
readonly subject: string;
|
||
readonly replacement: string;
|
||
readonly scanAll: boolean;
|
||
readonly timeoutMs: number;
|
||
readonly tests: readonly RegexTestCase[];
|
||
}
|
||
|
||
interface ValidationRecord {
|
||
readonly configuration: ValidationConfiguration;
|
||
readonly result: PatternFormatValidationResult;
|
||
}
|
||
|
||
interface ConfirmationRecord {
|
||
readonly configuration: ValidationConfiguration;
|
||
readonly checked: boolean;
|
||
}
|
||
|
||
function previewState(
|
||
flavour: RegexFlavourId,
|
||
pattern: string,
|
||
syntax: RegexSyntaxResult | undefined,
|
||
): PreviewState {
|
||
if (flavour !== "ecmascript") {
|
||
return {
|
||
status: "unavailable",
|
||
reason:
|
||
"Canonical formatting is currently available only for ECMAScript. PCRE2 and other flavours remain unchanged because no complete grammar-backed formatter is implemented for them.",
|
||
};
|
||
}
|
||
if (!syntax) {
|
||
return {
|
||
status: "unavailable",
|
||
reason:
|
||
"Wait for the ECMAScript syntax provider to finish parsing the current pattern.",
|
||
};
|
||
}
|
||
try {
|
||
return {
|
||
status: "available",
|
||
preview: formatEcmaScriptPattern(pattern, syntax),
|
||
};
|
||
} catch (cause) {
|
||
return {
|
||
status: "unavailable",
|
||
reason:
|
||
cause instanceof Error
|
||
? cause.message
|
||
: "The current syntax snapshot cannot be formatted.",
|
||
};
|
||
}
|
||
}
|
||
|
||
function formatDuration(milliseconds: number): string {
|
||
if (milliseconds < 1) return "<1 ms";
|
||
if (milliseconds < 1_000) return `${milliseconds.toFixed(0)} ms`;
|
||
return `${(milliseconds / 1_000).toFixed(2)} s`;
|
||
}
|
||
|
||
function observationIdentity(check: PatternFormatSnapshotCheck): string {
|
||
const before = check.before.engineIdentity ?? check.before.status;
|
||
const after = check.after.engineIdentity ?? check.after.status;
|
||
return before === after ? before : `${before} → ${after}`;
|
||
}
|
||
|
||
function ValidationCheck({
|
||
title,
|
||
check,
|
||
}: {
|
||
readonly title: string;
|
||
readonly check: PatternFormatSnapshotCheck;
|
||
}) {
|
||
return (
|
||
<article className={`formatter-check is-${check.comparison}`}>
|
||
<header>
|
||
<strong>{title}</strong>
|
||
<span>{check.comparison}</span>
|
||
</header>
|
||
<p>{check.summary}</p>
|
||
<small>{observationIdentity(check)}</small>
|
||
{check.mismatchKinds.length > 0 ? (
|
||
<code>{check.mismatchKinds.join(" · ")}</code>
|
||
) : null}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
export function PatternFormatterPanel({
|
||
flavour,
|
||
flavourVersion,
|
||
pattern,
|
||
flags,
|
||
options,
|
||
syntax,
|
||
subject,
|
||
replacement,
|
||
scanAll,
|
||
timeoutMs,
|
||
tests,
|
||
onApply,
|
||
onClose,
|
||
createValidator = () => new PatternFormatValidator(),
|
||
}: PatternFormatterPanelProps) {
|
||
const preview = useMemo(
|
||
() => previewState(flavour, pattern, syntax),
|
||
[flavour, pattern, syntax],
|
||
);
|
||
const configuration = useMemo<ValidationConfiguration | undefined>(
|
||
() =>
|
||
preview.status === "available"
|
||
? {
|
||
flavourVersion,
|
||
preview: preview.preview,
|
||
flags,
|
||
options,
|
||
subject,
|
||
replacement,
|
||
scanAll,
|
||
timeoutMs,
|
||
tests,
|
||
}
|
||
: undefined,
|
||
[
|
||
flags,
|
||
flavourVersion,
|
||
options,
|
||
preview,
|
||
replacement,
|
||
scanAll,
|
||
subject,
|
||
tests,
|
||
timeoutMs,
|
||
],
|
||
);
|
||
const validator = useRef<PatternFormatValidatorClient | undefined>(undefined);
|
||
const revision = useRef(0);
|
||
const [running, setRunning] = useState(false);
|
||
const [progress, setProgress] = useState({ completed: 0, total: 0 });
|
||
const [message, setMessage] = useState(
|
||
"Review the exact preview before running bounded validation.",
|
||
);
|
||
const [error, setError] = useState<string>();
|
||
const [record, setRecord] = useState<ValidationRecord>();
|
||
const [confirmation, setConfirmation] = useState<ConfirmationRecord>();
|
||
|
||
const currentRecord =
|
||
record?.configuration === configuration ? record : undefined;
|
||
const confirmed =
|
||
confirmation !== undefined &&
|
||
confirmation.configuration === configuration &&
|
||
confirmation.checked;
|
||
|
||
useEffect(
|
||
() => () => {
|
||
revision.current += 1;
|
||
validator.current?.dispose();
|
||
validator.current = undefined;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const cancel = () => {
|
||
revision.current += 1;
|
||
validator.current?.cancel();
|
||
setRunning(false);
|
||
setMessage("Validation cancelled; formatter workers were terminated.");
|
||
};
|
||
|
||
const runValidation = async () => {
|
||
if (
|
||
!configuration ||
|
||
!configuration.preview.changed ||
|
||
!configuration.preview.withinPatternLimit
|
||
) {
|
||
return;
|
||
}
|
||
const currentValidator =
|
||
validator.current ?? (validator.current = createValidator());
|
||
currentValidator.cancel();
|
||
revision.current += 1;
|
||
const runRevision = revision.current;
|
||
setRunning(true);
|
||
setConfirmation(undefined);
|
||
setError(undefined);
|
||
setRecord(undefined);
|
||
setProgress({ completed: 0, total: 0 });
|
||
setMessage(
|
||
"Reparsing, compiling and comparing exact source/candidate snapshots…",
|
||
);
|
||
try {
|
||
const result = await currentValidator.validate(
|
||
{
|
||
flavour: "ecmascript",
|
||
...(configuration.flavourVersion === undefined
|
||
? {}
|
||
: { flavourVersion: configuration.flavourVersion }),
|
||
sourcePattern: configuration.preview.sourcePattern,
|
||
formattedPattern: configuration.preview.formattedPattern,
|
||
flags: configuration.flags,
|
||
options: configuration.options,
|
||
subject: configuration.subject,
|
||
replacement: configuration.replacement,
|
||
scanAll: configuration.scanAll,
|
||
timeoutMs: configuration.timeoutMs,
|
||
tests: configuration.tests,
|
||
},
|
||
(completed, total) => {
|
||
if (revision.current !== runRevision) return;
|
||
setProgress({ completed, total });
|
||
setMessage(
|
||
total === 0
|
||
? "The current subject/replacement snapshot is being compared."
|
||
: `Validated ${completed.toLocaleString()} of ${total.toLocaleString()} applicable unit tests.`,
|
||
);
|
||
},
|
||
);
|
||
if (revision.current !== runRevision) return;
|
||
setRecord({ configuration, result });
|
||
setMessage(
|
||
result.status === "equivalent"
|
||
? "All completed safety gates retained the same observable behavior."
|
||
: result.status === "different"
|
||
? "Formatting changed at least one checked observation."
|
||
: "Validation was inconclusive; the candidate cannot be applied.",
|
||
);
|
||
} catch (cause) {
|
||
if (revision.current !== runRevision) return;
|
||
const cancelled =
|
||
cause instanceof WorkerRequestError && cause.kind === "cancelled";
|
||
setError(
|
||
cancelled
|
||
? "Validation was cancelled."
|
||
: cause instanceof Error
|
||
? cause.message
|
||
: "Formatter validation failed.",
|
||
);
|
||
setMessage(
|
||
cancelled
|
||
? "Validation cancelled; no candidate can be applied."
|
||
: "Validation failed; no candidate can be applied.",
|
||
);
|
||
} finally {
|
||
if (revision.current === runRevision) setRunning(false);
|
||
}
|
||
};
|
||
|
||
const close = () => {
|
||
if (running) cancel();
|
||
onClose?.();
|
||
};
|
||
|
||
const result = currentRecord?.result;
|
||
const canApply =
|
||
result?.canApply === true &&
|
||
confirmed &&
|
||
configuration?.preview.changed === true;
|
||
|
||
return (
|
||
<section
|
||
className="panel formatter-panel"
|
||
aria-labelledby="formatter-heading"
|
||
>
|
||
<header className="panel-heading">
|
||
<div>
|
||
<p className="eyebrow">
|
||
Grammar-backed preview + bounded proof gates
|
||
</p>
|
||
<h2 id="formatter-heading">Format pattern</h2>
|
||
</div>
|
||
<div className="panel-heading-actions">
|
||
<span className="provenance-badge">ECMAScript only</span>
|
||
{onClose ? (
|
||
<button
|
||
type="button"
|
||
className="icon-button"
|
||
aria-label="Close pattern formatter"
|
||
data-dialog-initial-focus
|
||
onClick={close}
|
||
>
|
||
×
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
{preview.status === "unavailable" ? (
|
||
<p className="formatter-unavailable" role="status">
|
||
{preview.reason}
|
||
</p>
|
||
) : (
|
||
<div className="formatter-body">
|
||
<p className="formatter-advisory">
|
||
This deliberately narrow formatter escapes literal slashes and raw
|
||
control/line-separator characters. It does not insert insignificant
|
||
whitespace—ECMAScript patterns generally have no such whitespace.
|
||
</p>
|
||
|
||
<section className="formatter-preview" aria-label="Format preview">
|
||
<header>
|
||
<div>
|
||
<p className="eyebrow">
|
||
{preview.preview.formatter.id} · v
|
||
{preview.preview.formatter.version}
|
||
</p>
|
||
<h3>Exact preview</h3>
|
||
</div>
|
||
<span>
|
||
{preview.preview.totalChanges.toLocaleString()} transformation
|
||
{preview.preview.totalChanges === 1 ? "" : "s"}
|
||
</span>
|
||
</header>
|
||
<div className="formatter-pattern-grid">
|
||
<article>
|
||
<strong>Source</strong>
|
||
<pre>
|
||
<code>{preview.preview.sourcePattern}</code>
|
||
</pre>
|
||
</article>
|
||
<article>
|
||
<strong>Formatted candidate</strong>
|
||
<pre data-testid="formatted-pattern-preview">
|
||
<code>{preview.preview.formattedPattern}</code>
|
||
</pre>
|
||
</article>
|
||
</div>
|
||
|
||
{preview.preview.changed ? (
|
||
<div className="formatter-table-scroll">
|
||
<table aria-label="Formatter transformations">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">UTF-16 range</th>
|
||
<th scope="col">Kind</th>
|
||
<th scope="col">Before</th>
|
||
<th scope="col">After</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{preview.preview.changes
|
||
.slice(0, MAXIMUM_VISIBLE_CHANGES)
|
||
.map((change) => (
|
||
<tr
|
||
key={`${change.startUtf16}:${change.endUtf16}:${change.kind}`}
|
||
title={change.explanation}
|
||
>
|
||
<td>
|
||
{change.startUtf16}–{change.endUtf16}
|
||
</td>
|
||
<td>{change.kind.replaceAll("-", " ")}</td>
|
||
<td>
|
||
<code>{JSON.stringify(change.before)}</code>
|
||
</td>
|
||
<td>
|
||
<code>{JSON.stringify(change.after)}</code>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{preview.preview.totalChanges > MAXIMUM_VISIBLE_CHANGES ? (
|
||
<p>
|
||
The table shows the first{" "}
|
||
{MAXIMUM_VISIBLE_CHANGES.toLocaleString()} transformations.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<p className="formatter-empty">
|
||
This pattern already uses the formatter’s canonical literal and
|
||
control escapes.
|
||
</p>
|
||
)}
|
||
|
||
<ul className="formatter-notices">
|
||
{preview.preview.warnings.map((warning) => (
|
||
<li key={warning}>{warning}</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
|
||
{preview.preview.changed ? (
|
||
<section className="formatter-validation">
|
||
<header>
|
||
<div>
|
||
<p className="eyebrow">Required before apply</p>
|
||
<h3>Safety validation</h3>
|
||
</div>
|
||
<span>
|
||
{result?.status ?? (running ? "running" : "not run")}
|
||
</span>
|
||
</header>
|
||
<div
|
||
className={`formatter-status is-${result?.status ?? (error ? "different" : "idle")}`}
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<strong>{message}</strong>
|
||
{error ? <p>{error}</p> : null}
|
||
</div>
|
||
{running ? (
|
||
<div className="formatter-progress">
|
||
<progress
|
||
max={Math.max(1, progress.total)}
|
||
value={Math.min(progress.completed, progress.total)}
|
||
/>
|
||
<span>
|
||
{progress.completed.toLocaleString()} /{" "}
|
||
{progress.total.toLocaleString()} tests
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
<div className="formatter-actions">
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
disabled={
|
||
running ||
|
||
!preview.preview.withinPatternLimit ||
|
||
!preview.preview.changed
|
||
}
|
||
onClick={() => void runValidation()}
|
||
>
|
||
Validate exact snapshots
|
||
</button>
|
||
{running ? (
|
||
<button
|
||
type="button"
|
||
className="secondary-button"
|
||
onClick={cancel}
|
||
>
|
||
Cancel
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{result ? (
|
||
<div className="formatter-result">
|
||
<dl>
|
||
<div>
|
||
<dt>Syntax</dt>
|
||
<dd>
|
||
{result.sourceSyntaxAccepted &&
|
||
result.formattedSyntaxAccepted
|
||
? "both accepted"
|
||
: "rejected"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Capture shape</dt>
|
||
<dd>
|
||
{result.captureShapePreserved ? "preserved" : "changed"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Unit tests</dt>
|
||
<dd>
|
||
{result.completedTestCount.toLocaleString()} /{" "}
|
||
{result.applicableTestCount.toLocaleString()} applicable
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Unrelated / disabled</dt>
|
||
<dd>{result.skippedTestCount.toLocaleString()}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Elapsed</dt>
|
||
<dd>{formatDuration(result.elapsedMs)}</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<div className="formatter-checks">
|
||
<ValidationCheck
|
||
title="Current subject and replacement"
|
||
check={result.current}
|
||
/>
|
||
{result.tests
|
||
.slice(0, MAXIMUM_VISIBLE_CHECKS)
|
||
.map((test) => (
|
||
<ValidationCheck
|
||
key={test.testId}
|
||
title={test.name}
|
||
check={test}
|
||
/>
|
||
))}
|
||
</div>
|
||
{result.tests.length > MAXIMUM_VISIBLE_CHECKS ? (
|
||
<p className="formatter-result-note">
|
||
The panel shows the first{" "}
|
||
{MAXIMUM_VISIBLE_CHECKS.toLocaleString()} completed test
|
||
comparisons.
|
||
</p>
|
||
) : null}
|
||
|
||
{result.differences.length > 0 ? (
|
||
<section
|
||
className="formatter-differences"
|
||
aria-labelledby="formatter-differences-heading"
|
||
>
|
||
<h4 id="formatter-differences-heading">
|
||
Differences and inconclusive gates
|
||
</h4>
|
||
<ul>
|
||
{result.differences
|
||
.slice(0, MAXIMUM_VISIBLE_CHECKS)
|
||
.map((difference, index) => (
|
||
<li
|
||
key={`${difference.code}:${difference.testId ?? ""}:${index}`}
|
||
>
|
||
<strong>{difference.code}</strong>{" "}
|
||
{difference.summary}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : null}
|
||
<ul className="formatter-notices">
|
||
{result.notices.map((notice) => (
|
||
<li key={notice}>{notice}</li>
|
||
))}
|
||
</ul>
|
||
|
||
<label className="check-control formatter-confirmation">
|
||
<input
|
||
type="checkbox"
|
||
checked={confirmed}
|
||
disabled={!result.canApply}
|
||
onChange={(event) => {
|
||
if (!configuration) return;
|
||
setConfirmation({
|
||
configuration,
|
||
checked: event.target.checked,
|
||
});
|
||
}}
|
||
/>
|
||
<span>
|
||
I understand that this bounded validation is not a proof
|
||
for every possible subject, and I want to apply the exact
|
||
candidate shown above.
|
||
</span>
|
||
</label>
|
||
<div className="formatter-actions">
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
disabled={!canApply}
|
||
onClick={() => {
|
||
if (!canApply || !configuration) return;
|
||
onApply(configuration.preview.formattedPattern);
|
||
}}
|
||
>
|
||
Apply formatted pattern
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|