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

618 lines
20 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 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
whitespaceECMAScript 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 formatters 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>
);
}