408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { Pcre2TraceSupervisor } from "../regex/execution/Pcre2TraceSupervisor";
|
||
import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
|
||
import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
|
||
import type {
|
||
RegexEngineOptions,
|
||
RegexFlavourId,
|
||
} from "../regex/model/flavour";
|
||
import type { Pcre2TraceEvent, Pcre2TraceResult } from "../regex/model/trace";
|
||
import type { SourceRange } from "../regex/model/syntax";
|
||
|
||
const DEFAULT_TRACE_EVENTS = 5_000;
|
||
const DEFAULT_TRACE_BYTES = 2 * 1024 * 1024;
|
||
const MAXIMUM_RENDERED_TRACE_EVENTS = 2_000;
|
||
|
||
type TraceState =
|
||
| { readonly status: "idle"; readonly message: string }
|
||
| { readonly status: "running"; readonly message: string }
|
||
| { readonly status: "ready"; readonly message: string }
|
||
| { readonly status: "timeout"; readonly message: string }
|
||
| { readonly status: "error"; readonly message: string };
|
||
|
||
function movementLabel(event: Pcre2TraceEvent): string {
|
||
switch (event.movement.classification) {
|
||
case "first-event":
|
||
return "first";
|
||
case "same-position":
|
||
return "same position";
|
||
case "apparent-backtrack":
|
||
return "apparent backtrack";
|
||
case "forward":
|
||
return "forward";
|
||
}
|
||
}
|
||
|
||
export function Pcre2TracePanel({
|
||
active,
|
||
flavour,
|
||
pattern,
|
||
flags,
|
||
options,
|
||
subject,
|
||
timeoutMs,
|
||
onClose,
|
||
onSelectRanges,
|
||
}: {
|
||
readonly active: boolean;
|
||
readonly flavour: RegexFlavourId;
|
||
readonly pattern: string;
|
||
readonly flags: readonly string[];
|
||
readonly options: RegexEngineOptions;
|
||
readonly subject: string;
|
||
readonly timeoutMs: number;
|
||
readonly onClose: () => void;
|
||
readonly onSelectRanges: (
|
||
patternRange: SourceRange,
|
||
subjectRange: SourceRange,
|
||
) => void;
|
||
}) {
|
||
const supervisor = useRef<Pcre2TraceSupervisor | null>(null);
|
||
const requestRevision = useRef(0);
|
||
const [maximumEvents, setMaximumEvents] = useState(DEFAULT_TRACE_EVENTS);
|
||
const [maximumBytes, setMaximumBytes] = useState(DEFAULT_TRACE_BYTES);
|
||
const [completed, setCompleted] = useState<{
|
||
readonly result: Pcre2TraceResult;
|
||
readonly input: {
|
||
readonly pattern: string;
|
||
readonly subject: string;
|
||
readonly flags: string;
|
||
readonly options: string;
|
||
};
|
||
}>();
|
||
const [state, setState] = useState<TraceState>({
|
||
status: "idle",
|
||
message: "Run an isolated PCRE2 automatic-callout trace.",
|
||
});
|
||
|
||
const traceEngine = () => {
|
||
supervisor.current ??= new Pcre2TraceSupervisor();
|
||
return supervisor.current;
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => supervisor.current?.dispose();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (active) return;
|
||
supervisor.current?.cancel();
|
||
}, [active]);
|
||
|
||
const limitsValid =
|
||
Number.isSafeInteger(maximumEvents) &&
|
||
maximumEvents >= 1 &&
|
||
maximumEvents <= DEFAULT_REGEX_LIMITS.maximumTraceEvents &&
|
||
Number.isSafeInteger(maximumBytes) &&
|
||
maximumBytes >= 32 &&
|
||
maximumBytes <= DEFAULT_REGEX_LIMITS.maximumTraceBytes;
|
||
|
||
const runTrace = async () => {
|
||
if (flavour !== "pcre2" || !limitsValid) return;
|
||
const revision = (requestRevision.current += 1);
|
||
setCompleted(undefined);
|
||
setState({
|
||
status: "running",
|
||
message: "Collecting reported callouts in a dedicated worker…",
|
||
});
|
||
try {
|
||
const next = await traceEngine().trace(
|
||
{
|
||
flavour,
|
||
pattern,
|
||
flags,
|
||
options,
|
||
subject,
|
||
maximumTraceEvents: maximumEvents,
|
||
maximumTraceBytes: maximumBytes,
|
||
},
|
||
timeoutMs,
|
||
);
|
||
if (revision !== requestRevision.current) return;
|
||
setCompleted({
|
||
result: next,
|
||
input: {
|
||
pattern,
|
||
subject,
|
||
flags: flags.join(""),
|
||
options: JSON.stringify(options),
|
||
},
|
||
});
|
||
const error = next.diagnostics.find(
|
||
(diagnostic) => diagnostic.severity === "error",
|
||
);
|
||
setState(
|
||
next.accepted
|
||
? {
|
||
status: "ready",
|
||
message: next.truncated
|
||
? "Bounded trace complete; collection stopped at a configured cap."
|
||
: "Bounded trace complete.",
|
||
}
|
||
: {
|
||
status: "error",
|
||
message: error?.message ?? "PCRE2 rejected the trace request.",
|
||
},
|
||
);
|
||
} catch (error) {
|
||
if (revision !== requestRevision.current) return;
|
||
if (error instanceof WorkerRequestError && error.kind === "timeout") {
|
||
setState({
|
||
status: "timeout",
|
||
message: `${error.message}; the trace worker was terminated.`,
|
||
});
|
||
} else if (
|
||
error instanceof WorkerRequestError &&
|
||
error.kind === "cancelled"
|
||
) {
|
||
setState({
|
||
status: "idle",
|
||
message: "Trace cancelled; its worker was terminated.",
|
||
});
|
||
} else {
|
||
setState({
|
||
status: "error",
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: "The PCRE2 trace worker failed.",
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
const resultIsCurrent =
|
||
completed?.input.pattern === pattern &&
|
||
completed.input.subject === subject &&
|
||
completed.input.flags === flags.join("") &&
|
||
completed.input.options === JSON.stringify(options);
|
||
const visibleResult = resultIsCurrent ? completed.result : undefined;
|
||
const visibleState =
|
||
resultIsCurrent || state.status === "running"
|
||
? state
|
||
: {
|
||
status: "idle" as const,
|
||
message: "Inputs changed; run a new isolated trace.",
|
||
};
|
||
const renderedEvents =
|
||
visibleResult?.events.slice(0, MAXIMUM_RENDERED_TRACE_EVENTS) ?? [];
|
||
return (
|
||
<section className="panel trace-panel" aria-labelledby="trace-heading">
|
||
<header className="panel-heading">
|
||
<div>
|
||
<p className="eyebrow">PCRE2 10.47 · reported automatic callouts</p>
|
||
<h2 id="trace-heading">Execution trace</h2>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="icon-button"
|
||
aria-label="Close execution trace"
|
||
data-dialog-initial-focus
|
||
onClick={onClose}
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
<p className="trace-disclosure">
|
||
This is an actual stream reported by PCRE2_AUTO_CALLOUT for one{" "}
|
||
<code>pcre2_match()</code> invocation. Movement labels are derived only
|
||
from adjacent reported subject positions; the stream is not a complete
|
||
record of every internal engine action.
|
||
</p>
|
||
<div className="trace-controls">
|
||
<label>
|
||
<span>Maximum events</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={DEFAULT_REGEX_LIMITS.maximumTraceEvents}
|
||
step={1}
|
||
value={maximumEvents}
|
||
onChange={(event) => setMaximumEvents(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Maximum serialized bytes</span>
|
||
<input
|
||
type="number"
|
||
min={32}
|
||
max={DEFAULT_REGEX_LIMITS.maximumTraceBytes}
|
||
step={1024}
|
||
value={maximumBytes}
|
||
onChange={(event) => setMaximumBytes(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="primary-button"
|
||
disabled={
|
||
flavour !== "pcre2" ||
|
||
!limitsValid ||
|
||
visibleState.status === "running"
|
||
}
|
||
onClick={() => void runTrace()}
|
||
>
|
||
{visibleState.status === "running" ? "Tracing…" : "Run trace"}
|
||
</button>
|
||
{visibleState.status === "running" ? (
|
||
<button
|
||
type="button"
|
||
className="secondary-button"
|
||
onClick={() => {
|
||
requestRevision.current += 1;
|
||
supervisor.current?.cancel();
|
||
setState({
|
||
status: "idle",
|
||
message: "Trace cancelled; its worker was terminated.",
|
||
});
|
||
}}
|
||
>
|
||
Cancel trace
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{flavour !== "pcre2" ? (
|
||
<p className="empty-state" role="status">
|
||
Select PCRE2 to enable its automatic-callout trace.
|
||
</p>
|
||
) : null}
|
||
<div
|
||
className={`trace-status status-${visibleState.status}`}
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<strong>{visibleState.message}</strong>
|
||
{visibleResult?.accepted ? (
|
||
<small>
|
||
{visibleResult.events.length.toLocaleString()} complete retained
|
||
events of {visibleResult.totalEventCount.toLocaleString()} callback
|
||
event(s) observed before return ·{" "}
|
||
{visibleResult.traceBytes.toLocaleString()} serialized bytes · last
|
||
complete event{" "}
|
||
{visibleResult.lastCompleteEvent?.toLocaleString() ?? "none"} ·{" "}
|
||
native status {visibleResult.nativeMatchStatus} ·{" "}
|
||
{visibleResult.matched ? "matched" : "not matched or bounded stop"}{" "}
|
||
· {visibleResult.elapsedMs.toFixed(2)} ms
|
||
</small>
|
||
) : null}
|
||
</div>
|
||
{visibleResult?.diagnostics.length ? (
|
||
<ul className="trace-diagnostics">
|
||
{visibleResult.diagnostics.map((diagnostic) => (
|
||
<li
|
||
key={diagnostic.id}
|
||
className={`diagnostic-${diagnostic.severity}`}
|
||
>
|
||
<strong>{diagnostic.severity}</strong> {diagnostic.message}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
{visibleResult && visibleResult.events.length > renderedEvents.length ? (
|
||
<p className="render-limit-banner" role="status">
|
||
<strong>Trace viewer limited.</strong> Showing the first{" "}
|
||
{renderedEvents.length.toLocaleString()} of{" "}
|
||
{visibleResult.events.length.toLocaleString()} complete retained
|
||
events. The worker result remains bounded by both configured caps.
|
||
</p>
|
||
) : null}
|
||
{renderedEvents.length > 0 ? (
|
||
<div className="trace-table-scroll">
|
||
<table className="trace-table">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Event</th>
|
||
<th scope="col">Callout</th>
|
||
<th scope="col">Pattern</th>
|
||
<th scope="col">Next item</th>
|
||
<th scope="col">Subject</th>
|
||
<th scope="col">Captures</th>
|
||
<th scope="col">Mark</th>
|
||
<th scope="col">Movement</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{renderedEvents.map((event) => (
|
||
<tr key={event.eventNumber}>
|
||
<td>
|
||
<button
|
||
type="button"
|
||
className="trace-event-select"
|
||
aria-label={`Select trace event ${event.eventNumber}`}
|
||
onClick={() =>
|
||
onSelectRanges(
|
||
{
|
||
startUtf16:
|
||
event.reported.nextPatternItem.startUtf16,
|
||
endUtf16: event.reported.nextPatternItem.endUtf16,
|
||
},
|
||
{
|
||
startUtf16: event.reported.subjectPosition.utf16,
|
||
endUtf16: event.reported.subjectPosition.utf16,
|
||
},
|
||
)
|
||
}
|
||
>
|
||
{event.eventNumber}
|
||
</button>
|
||
</td>
|
||
<td>{event.reported.calloutNumber}</td>
|
||
<td>
|
||
{event.reported.patternPosition.utf16} UTF-16
|
||
<small>
|
||
{event.reported.patternPosition.nativeByte} byte
|
||
</small>
|
||
</td>
|
||
<td>
|
||
<code>
|
||
{pattern.slice(
|
||
event.reported.nextPatternItem.startUtf16,
|
||
event.reported.nextPatternItem.endUtf16,
|
||
) || "end"}
|
||
</code>
|
||
<small>
|
||
{event.reported.nextPatternItem.startNativeByte}…
|
||
{event.reported.nextPatternItem.endNativeByte} bytes
|
||
</small>
|
||
</td>
|
||
<td>
|
||
{event.reported.subjectPosition.utf16} UTF-16
|
||
<small>
|
||
{event.reported.subjectPosition.nativeByte} byte
|
||
</small>
|
||
</td>
|
||
<td>
|
||
top {event.reported.captureTop} · last{" "}
|
||
{event.reported.captureLast}
|
||
</td>
|
||
<td>
|
||
{event.reported.mark ? (
|
||
<code>{event.reported.mark}</code>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</td>
|
||
<td>
|
||
<span
|
||
className={`trace-movement trace-${event.movement.classification}`}
|
||
title={event.movement.explanation}
|
||
>
|
||
{movementLabel(event)}
|
||
</span>
|
||
<small>
|
||
{event.movement.subjectDeltaBytes > 0 ? "+" : ""}
|
||
{event.movement.subjectDeltaBytes} bytes · derived
|
||
</small>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : visibleResult?.accepted ? (
|
||
<p className="empty-state">No callout event was retained.</p>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|