242 lines
7.4 KiB
TypeScript
242 lines
7.4 KiB
TypeScript
import { useMemo } from "react";
|
|
import type { NormalizedRegexNode } from "../regex/model/syntax";
|
|
import { handleTreeNavigation } from "./tree-navigation";
|
|
|
|
const MAXIMUM_RENDERED_EXPLANATION_NODES = 2_000;
|
|
|
|
export interface ExplanationTreeProps {
|
|
readonly root?: NormalizedRegexNode;
|
|
readonly selectedId?: string;
|
|
readonly flags?: readonly string[];
|
|
readonly stale?: boolean;
|
|
readonly onSelect: (node: NormalizedRegexNode) => void;
|
|
}
|
|
|
|
function countNodes(root: NormalizedRegexNode): number {
|
|
let count = 0;
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const node = pending.pop();
|
|
if (!node) continue;
|
|
count += 1;
|
|
for (const child of node.children) pending.push(child);
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function boundedRoot(
|
|
root: NormalizedRegexNode,
|
|
maximum: number,
|
|
): NormalizedRegexNode {
|
|
let remaining = maximum;
|
|
const takeNode = (
|
|
node: NormalizedRegexNode,
|
|
): NormalizedRegexNode | undefined => {
|
|
if (remaining <= 0) return undefined;
|
|
remaining -= 1;
|
|
const children: NormalizedRegexNode[] = [];
|
|
for (const child of node.children) {
|
|
const visible = takeNode(child);
|
|
if (!visible) break;
|
|
children.push(visible);
|
|
}
|
|
return { ...node, children };
|
|
};
|
|
return takeNode(root) ?? root;
|
|
}
|
|
|
|
function containsNode(root: NormalizedRegexNode, id: string): boolean {
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const node = pending.pop();
|
|
if (!node) continue;
|
|
if (node.id === id) return true;
|
|
for (const child of node.children) pending.push(child);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function lengthLabel(node: NormalizedRegexNode): string {
|
|
const minimum = node.properties.minimumLength;
|
|
const maximum = node.properties.maximumLength;
|
|
if (minimum === undefined && maximum === undefined) return "length unknown";
|
|
return `${minimum ?? "?"}…${maximum === null ? "∞" : (maximum ?? "?")}`;
|
|
}
|
|
|
|
function consumesInputLabel(
|
|
value: NormalizedRegexNode["properties"]["consumesInput"],
|
|
): string {
|
|
return value === true ? "yes" : value === false ? "no" : value;
|
|
}
|
|
|
|
function nullableLabel(
|
|
value: NormalizedRegexNode["properties"]["nullable"],
|
|
): string {
|
|
return value === true ? "yes" : value === false ? "no" : value;
|
|
}
|
|
|
|
function ExplanationNode({
|
|
node,
|
|
selectedId,
|
|
onSelect,
|
|
depth,
|
|
focusableId,
|
|
flags,
|
|
}: {
|
|
readonly node: NormalizedRegexNode;
|
|
readonly selectedId?: string;
|
|
readonly onSelect: (node: NormalizedRegexNode) => void;
|
|
readonly depth: number;
|
|
readonly focusableId: string;
|
|
readonly flags: readonly string[];
|
|
}) {
|
|
const compatibility =
|
|
node.support.notes.length > 0
|
|
? node.support.notes.slice(0, 2).join("; ").slice(0, 240)
|
|
: "no parser warning";
|
|
return (
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
role="treeitem"
|
|
aria-level={depth}
|
|
aria-selected={node.id === selectedId}
|
|
tabIndex={node.id === focusableId ? 0 : -1}
|
|
className={`tree-row ${node.id === selectedId ? "is-selected" : ""}`}
|
|
onClick={() => onSelect(node)}
|
|
onKeyDown={handleTreeNavigation}
|
|
>
|
|
<span className="tree-token">{node.raw || "∅"}</span>
|
|
<span className="tree-explanation">{node.explanation}</span>
|
|
<span className="tree-meta">
|
|
{node.capture
|
|
? `group ${node.capture.number}${node.capture.name ? ` · ${node.capture.name}` : ""}`
|
|
: node.quantifier
|
|
? `${node.quantifier.minimum}…${node.quantifier.maximum ?? "∞"} · ${node.quantifier.mode}`
|
|
: node.properties.zeroWidth
|
|
? "zero-width"
|
|
: lengthLabel(node)}
|
|
</span>
|
|
<span className="tree-details">
|
|
<span>
|
|
range: {node.range.startUtf16}…{node.range.endUtf16} UTF-16
|
|
</span>
|
|
<span>
|
|
consumes: {consumesInputLabel(node.properties.consumesInput)}
|
|
</span>
|
|
<span>empty: {nullableLabel(node.properties.nullable)}</span>
|
|
<span>length: {lengthLabel(node)}</span>
|
|
<span>
|
|
active flags:{" "}
|
|
{(node.activeFlags ?? flags).length > 0
|
|
? (node.activeFlags ?? flags).join("")
|
|
: "none"}
|
|
</span>
|
|
<span>
|
|
flavour: {node.support.flavour} · {node.support.status}
|
|
</span>
|
|
<span>compatibility: {compatibility}</span>
|
|
<span>risk: not assessed in this milestone</span>
|
|
<span>
|
|
parser: {node.provenance.provider} {node.provenance.providerVersion}{" "}
|
|
· {node.provenance.source}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{node.children.length > 0 ? (
|
|
<ul role="group">
|
|
{node.children.map((child) => (
|
|
<ExplanationNode
|
|
key={child.id}
|
|
node={child}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
depth={depth + 1}
|
|
focusableId={focusableId}
|
|
flags={flags}
|
|
/>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function ExplanationTree({
|
|
root,
|
|
selectedId,
|
|
flags = [],
|
|
stale = false,
|
|
onSelect,
|
|
}: ExplanationTreeProps) {
|
|
const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]);
|
|
const visibleRoot = useMemo(
|
|
() =>
|
|
root ? boundedRoot(root, MAXIMUM_RENDERED_EXPLANATION_NODES) : undefined,
|
|
[root],
|
|
);
|
|
const renderedNodes = Math.min(
|
|
totalNodes,
|
|
MAXIMUM_RENDERED_EXPLANATION_NODES,
|
|
);
|
|
const selectedNodeVisible =
|
|
selectedId === undefined ||
|
|
visibleRoot === undefined ||
|
|
containsNode(visibleRoot, selectedId);
|
|
const focusableId =
|
|
selectedId && selectedNodeVisible ? selectedId : (visibleRoot?.id ?? "");
|
|
|
|
return (
|
|
<section className="panel tree-panel" aria-labelledby="explanation-heading">
|
|
<header className="panel-heading">
|
|
<div>
|
|
<p className="eyebrow">Pattern structure</p>
|
|
<h2 id="explanation-heading">Explanation tree</h2>
|
|
</div>
|
|
<span
|
|
className={`provenance-badge ${stale ? "is-stale" : ""}`}
|
|
data-testid={stale ? "stale-pattern-syntax" : undefined}
|
|
title={
|
|
stale
|
|
? "This is the last parsed pattern. Its ranges are not applied to the current editor."
|
|
: undefined
|
|
}
|
|
>
|
|
{stale
|
|
? "Stale snapshot · checking current pattern"
|
|
: "Deterministic · syntax provider"}
|
|
</span>
|
|
</header>
|
|
{totalNodes > renderedNodes ? (
|
|
<p
|
|
className="render-limit-banner"
|
|
role="status"
|
|
data-testid="explanation-render-limit"
|
|
>
|
|
<strong>Tree preview limited.</strong> Showing the first{" "}
|
|
{renderedNodes.toLocaleString()} of {totalNodes.toLocaleString()}{" "}
|
|
normalized syntax nodes. The complete normalized syntax tree remains
|
|
available to the application.
|
|
{!selectedNodeVisible
|
|
? " The selected pattern node is outside this bounded tree preview."
|
|
: ""}
|
|
</p>
|
|
) : null}
|
|
{visibleRoot ? (
|
|
<ul className="tree" role="tree" aria-label="Pattern explanation">
|
|
<ExplanationNode
|
|
node={visibleRoot}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
depth={1}
|
|
focusableId={focusableId}
|
|
flags={flags}
|
|
/>
|
|
</ul>
|
|
) : (
|
|
<p className="empty-state">The syntax worker is preparing the tree.</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|