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

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>
);
}