feat: publish Regex Tools 0.2.0
This commit is contained in:
617
src/components/PatternFormatterPanel.tsx
Normal file
617
src/components/PatternFormatterPanel.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user