diff --git a/.eslintrc.json b/.eslintrc.json index 4d54b63c..4934137b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,7 +9,32 @@ "@typescript-eslint" ], "rules": { - "@typescript-eslint/naming-convention": "warn", + "@typescript-eslint/naming-convention": [ + "warn", + { + "selector": "default", + "format": ["camelCase"], + "leadingUnderscore": "allow", + "trailingUnderscore": "allow" + }, + { + "selector": "variable", + "format": ["camelCase", "UPPER_CASE"], + "leadingUnderscore": "allow", + "trailingUnderscore": "allow" + }, + { + "selector": "typeLike", + "format": ["PascalCase"] + }, + { + // Keys of a foreign API, quoted because they have to be - the dotted ELK + // layout options have no camelCase spelling to pick. + "selector": ["objectLiteralProperty", "typeProperty"], + "modifiers": ["requiresQuotes"], + "format": null + } + ], "@typescript-eslint/semi": "warn", "curly": "warn", "eqeqeq": "warn", diff --git a/README.md b/README.md index 8e39ad27..a31a1de3 100644 --- a/README.md +++ b/README.md @@ -218,3 +218,14 @@ You can debug the extension from Visual Studio Code: * Open the main folder of the plugin with vscode. * Open the file `extension.ts`. * Choose "Run" from the menu, then "Start Debugging". + +## WebDev + +You can develop the design of the visualization using an example trace in your browser: + +* `npm install` +* `npm run build` +* `npm run watch:web` this starts a process which will build the contents of `src/programflow-visualization/web` into `out/programflow-visualization/web` on any changes +* In console: `cd out/programflow-visualization/web` + `python3 -m http.server 5173` +* Then open http://localhost:5173/index.web.html in your browser +* Now you can edit files in `src/programflow-visualization/web`, watch:web will rebuild automatically and you can refresh your browser tab to see the changes instantly \ No newline at end of file diff --git a/elk-task/current.png b/elk-task/current.png new file mode 100644 index 00000000..9c6d2afa Binary files /dev/null and b/elk-task/current.png differ diff --git a/elk-task/elk-plan.md b/elk-task/elk-plan.md new file mode 100644 index 00000000..eed483a8 --- /dev/null +++ b/elk-task/elk-plan.md @@ -0,0 +1,733 @@ +# ELK-based visualization: plan + +Replace the current CSS-flexbox + linkerline rendering of the programflow visualization +with a real graph model laid out by [elkjs](https://github.com/kieler/elkjs), add +collapsible heap nodes, and make the whole thing themable from CSS. + +`example.elkt` in the repo root is the hand-written target shape (prototype for +). It is a *specification*, +not a runtime artifact: elkjs consumes a JSON graph, not the `.elkt` text notation. + +--- + +## 1. Where we are today + +| Concern | Current implementation | +| --- | --- | +| Model | none — `html-generator.ts` builds two raw HTML strings (`stackHTML`, `heapHTML`) | +| Layout | CSS flex, two floated columns (`.floating-left` = frames 35%, `.floating-right` = objects 65%) | +| Edges | `linkerline`, drawn *after* render by measuring DOM elements found via `id="…Pointer"` / `id="heapEndPointer"` regex-scraped out of the HTML strings | +| Styling | hardcoded colors in `webview.css` (`.box`, `.frame`, `.current-frame`, …) | +| Collapsing | not supported | + +Blast radius is small: `FrontendTraceElem` / `HTMLGenerator` are referenced **only** by +`web/webview.ts` and `web/html-generator.ts`. The extension host +(`frontend/visualization_panel.ts`) only ships `BackendTraceElem`s, so nothing on the +VS Code side changes. + +## 2. Constraints found while checking the code + +- **`heap` is not a `Map`.** `types.ts` declares `heap: Map`, but after + IPC/JSON it is a plain object — today's code works around this with `Object.keys(...)`. + Same for `HeapValue.value` / `.keys` on `dict` and `instance`. Use `Object.entries`. +- **CSP blocks Web Workers.** `web/index.html` has `default-src 'none'` and no + `worker-src`, so `worker-src` falls back to `none`. → Use the synchronous bundled build + `elkjs/lib/elk.bundled.js` (main thread, no worker). Revisit the worker build only if + layout is too slow; that would also need `worker-src blob:;` in the CSP. +- **Bundling, and minification is now mandatory.** `elk.bundled.js` is plain JS and bundles + fine into `webview.js` via esbuild; elkjs ships its own type declarations, so no + `@types/*` package. Measured by the spike (`elk-task/spike/spike.mjs`): + + | | size | + | --- | --- | + | `webview.js` today | 161 KiB | + | elkjs bundled, unminified | 3423 KiB | + | elkjs bundled, minified | 1426 KiB | + + esbuild is currently run **without** `minify`, so bundling elkjs as-is would grow the + webview payload 21×. `scripts/build-web.mjs` must set `minify: true` (at least for + non-watch builds) before elkjs goes in. Even then it is ~1.4 MiB, so if webview startup + suffers, the fallback is a separate ` - - Code Visualization - - -
-
-
- Frames -
-
-
- Objects -
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-

Step 

-

0

-

/?

-
-
- - - - -
-
-
-

-          
-        
- - - `; + private async postAppend(elem: BackendTraceElem) { + if (!this._panel) {return;} + + await this._panel.webview.postMessage({ + command: "append", + elem, + complete: this._backendTrace.complete, + }); } - private async updateLineHighlight(remove: boolean = false) { + // Editor highlighting + private async updateLineHighlight(remove: boolean = false, overrideFile?: string, overrideLine?: number) { try { - if (this._trace.length === 0) { - this._outChannel.appendLine("updateLineHighlight: no trace available, aborting"); - return; + let traceFile: string; + let traceLine: number; + + if (overrideFile !== undefined && overrideLine !== undefined) { + traceFile = overrideFile; + traceLine = overrideLine; + } else if (this._highlightFilePath !== undefined && this._highlightLine !== undefined) { + traceFile = this._highlightFilePath; + traceLine = this._highlightLine; + } else { + if (this._backendTrace.trace.length === 0) { + return; + } + const current = this._backendTrace.trace[0]; + traceFile = current.filePath; + traceLine = current.line; } - const traceFile = this._trace[this._traceIndex].filename; - this._outChannel.appendLine( - `updateLineHighlight: traceFile=${traceFile}, traceIndex=${this._traceIndex}, remove=${remove}`); - // Use vscode.Uri.file() for proper file path to URI conversion + this._highlightFilePath = traceFile; + this._highlightLine = traceLine; + const openPath = vscode.Uri.file(traceFile); - // Find editor by full normalized path, not just basename let editor: vscode.TextEditor | undefined = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.fsPath === openPath.fsPath + (ed) => ed.document.uri.fsPath === openPath.fsPath ); - if (!editor && remove) { - return; - } else if (!editor){ - this._outChannel.appendLine(`updateLineHighlight: editor not found, opening document: ${openPath.fsPath}`); - await vscode.commands.executeCommand('workbench.action.focusFirstEditorGroup'); - const document = await vscode.workspace.openTextDocument(openPath); - editor = await vscode.window.showTextDocument(document, { preserveFocus: false }); - // Give the editor time to fully initialize - await new Promise(resolve => setTimeout(resolve, 100)); - if (!editor) { - this._outChannel.appendLine(`updateLineHighlight: failed to get editor after opening document`); - return; - } + if (!editor && !remove) { + await vscode.commands.executeCommand("workbench.action.focusFirstEditorGroup"); + const doc = await vscode.workspace.openTextDocument(openPath); + editor = await vscode.window.showTextDocument(doc, { preserveFocus: false }); + await new Promise((r) => setTimeout(r, 50)); } - const traceLine = this._trace[this._traceIndex].lineNumber; - const lineNo = traceLine - 1; // zero-based indexing in vscode + if (!editor) {return;} + if (remove) { - this._outChannel.appendLine( - "updateLineHighlight: removing highlighting in " + editor.document.fileName); editor.setDecorations(nextLineExecuteHighlightType, []); - } else if (lineNo < 0 || lineNo >= editor.document.lineCount) { - this._outChannel.appendLine( - "updateLineHighlight: traceLine " + traceLine + " out of range (doc has " + - editor.document.lineCount + " lines) in " + editor.document.fileName); - editor.setDecorations(nextLineExecuteHighlightType, []); - } else { - this._outChannel.appendLine( - "updateLineHighlight: highlighting line " + traceLine + " in " + editor.document.fileName); - this.setEditorDecorations(editor, nextLineExecuteHighlightType, lineNo); - } - } catch (error) { - this._outChannel.appendLine(`updateLineHighlight: ERROR - ${error}`); - if (error instanceof Error) { - this._outChannel.appendLine(`Stack: ${error.stack}`); + return; } - } - } - - private setEditorDecorations(editor: vscode.TextEditor, highlightType: vscode.TextEditorDecorationType, line: number) { - editor.setDecorations( - highlightType, - this.createDecorationOptions( - new vscode.Range(new vscode.Position(line, 0), new vscode.Position(line, 999)) - ) - ); - } - - private async onClick(type: string) { - this.updateTraceIndex(type); - await this.postMessagesToWebview('updateButtons', 'updateContent'); - this.updateLineHighlight(); - } - private async onSlide(sliderValue: number) { - this._traceIndex = Number(sliderValue); - await this.postMessagesToWebview('updateButtons', 'updateContent'); - this.updateLineHighlight(); - } + const lineNo = traceLine - 1; + if (lineNo < 0 || lineNo >= editor.document.lineCount) { + editor.setDecorations(nextLineExecuteHighlightType, []); + return; + } - private updateTraceIndex(actionType: string) { - switch (actionType) { - case 'next': ++this._traceIndex; - break; - case 'prev': --this._traceIndex; - break; - case 'first': this._traceIndex = 0; - break; - case 'last': this._traceIndex = this._trace.length - 1; - break; - default: - break; + const range = new vscode.Range(new vscode.Position(lineNo, 0), new vscode.Position(lineNo, 999)); + editor.revealRange(range, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + editor.setDecorations(nextLineExecuteHighlightType, [{ range }]); + } catch (e: any) { + this._outChannel.appendLine(`updateLineHighlight failed: ${e?.message ?? String(e)}`); } } +} - private async postMessagesToWebview(...args: string[]) { - for (const message of args) { - switch (message) { - case 'updateButtons': - const nextActive = this._traceIndex < this._trace.length - 1; - const prevActive = this._traceIndex > 0; - const firstActive = this._traceIndex > 0; - const lastActive = this._traceIndex !== this._trace.length - 1; - await this._panel!.webview.postMessage({ - command: 'updateButtons', - next: nextActive, - prev: prevActive, - first: firstActive, - last: lastActive, - }); - break; - case 'updateContent': - await this._panel!.webview.postMessage({ - command: 'updateContent', - traceComplete: this._backendTrace.complete, - traceElem: this._trace[this._traceIndex], - traceIndex: this._traceIndex, - traceLen: this._trace.length, - }); - break; - } - }; - } +function getNonce(): string { + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let text = ""; + for (let i = 0; i < 32; i++) {text += possible.charAt(Math.floor(Math.random() * possible.length));} + return text; +} - private createDecorationOptions(range: vscode.Range): vscode.DecorationOptions[] { - return [ - { - range: range, - }, - ]; - } +function replaceAll(str: string, search: string, replacement: string): string { + return str.split(search).join(replacement); } diff --git a/src/programflow-visualization/graph-model.ts b/src/programflow-visualization/graph-model.ts new file mode 100644 index 00000000..d05c6a6d --- /dev/null +++ b/src/programflow-visualization/graph-model.ts @@ -0,0 +1,333 @@ +// Builds the ELK graph for one trace step. See elk-task/elk-plan.md 6.1. +// +// Structure only: no sizes, no DOM. measure.ts fills width/height and port +// positions, then elk.layout() assigns coordinates. +import type { ElkExtendedEdge, ElkNode, ElkPort } from "elkjs/lib/elk-api"; +import type { Address, BackendTraceElem, HeapValue, Value } from "./types"; +import { asRecord, outgoingRefs, visibleAddresses } from "./reachability"; + +export type NodeKind = + | "frame" + | "current-frame" + | "list" + | "tuple" + | "set" + | "dict" + | "instance"; + +/** One `key | value` line inside a node. `ref` is set when the line holds a reference. */ +export type RowModel = { + key: string; + value: string; + ref?: Address; + /** + * Set when the *key* is itself a reference, which only dicts can produce + * (`{(1, 2): "pair"}`). Such a key needs its own arrow, or the key object shows up + * with nothing pointing at it and ELK lays it out as a root, left of the frames. + */ + keyRef?: Address; + /** `return` rows are highlighted, as they are today. */ + isReturn?: boolean; +}; + +export type NodeModel = { + id: string; + kind: NodeKind; + header: string; + rows: RowModel[]; + /** Heap address, absent for frames. Only object nodes can be collapsed. */ + address?: Address; + collapsed: boolean; + /** Shown instead of the rows while collapsed, e.g. "12 elements". */ + summary: string; +}; + +export type VizGraph = { + /** Handed to elk.layout(). Node content is *not* in here — ELK may drop unknown fields. */ + graph: ElkNode; + /** Node id -> content, for measure.ts and graph-renderer.ts. */ + nodes: Map; +}; + +/** + * Layout options settled by the spike, whose findings are in elk-task/elk-plan.md 7.1: + * - cycleBreaking stays at its GREEDY default, or the frames' layer constraint throws + * as soon as an edge is reversed into a frame (plan 4). + * - considerModelOrder is deliberately absent: it buys no stability and costs 2.5x + * (plan 6.5). Stability comes from emitting nodes in a deterministic order. + * - semiInteractive is *not* here: it is switched on per step by buildGraph, and only + * when there is more than one frame to order. See FRAME_ORDER_OPTIONS (plan 7.10). + */ +export const LAYOUT_OPTIONS: Record = { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.edgeRouting": "ORTHOGONAL", + "elk.spacing.nodeNode": "25", + "elk.layered.spacing.nodeNodeBetweenLayers": "60", +}; + +/** + * Makes crossing minimisation honour the elk.position hints on the frame nodes. + * Without it the frame layer is ordered by barycentre, which lets sibling frames swap + * places from one step to the next. + * + * It is not free: the option is graph-wide, and nodes that carry no hint of their own + * get an interpolated one, so the heap column loses the unconstrained barycentre + * heuristic too. On this trace that collateral is worth ~35% more crossings, which is + * why it is only switched on for the steps that actually need it (plan 7.10). + */ +const FRAME_ORDER_OPTIONS: Record = { + "elk.layered.crossingMinimization.semiInteractive": "true", +}; + +export const frameNodeId = (index: number): string => `frame:${index}`; +export const objectNodeId = (address: Address): string => `obj:${address}`; +export const rowPortId = (nodeId: string, rowIndex: number): string => + `${nodeId}:${rowIndex}`; +/** Second port on a dict row whose key is a reference. */ +export const keyPortId = (nodeId: string, rowIndex: number): string => + `${nodeId}:${rowIndex}k`; +export const inputPortId = (nodeId: string): string => `${nodeId}:in`; + +/** Primitive rendering, matching html-generator.getCorrectValueOf. */ +export function formatValue(value: Value): string { + switch (value.type) { + case "ref": + return ""; + case "none": + return "None"; + default: + return String(value.value); + } +} + +function dictKeyLabel(key: Value | undefined): string { + if (!key) { + return ""; + } + // A reference key has no text of its own; the arrow leaving the key cell says + // what it is. The bracket keeps the cell from looking empty. + return key.type === "ref" ? "[key]" : formatValue(key); +} + +function headerOf(heapValue: HeapValue): string { + return heapValue.type === "instance" ? heapValue.name : heapValue.type; +} + +function summaryOf(heapValue: HeapValue, rowCount: number): string { + switch (heapValue.type) { + case "dict": + return `${rowCount} ${rowCount === 1 ? "entry" : "entries"}`; + case "instance": + return `${rowCount} ${rowCount === 1 ? "field" : "fields"}`; + default: + return `${rowCount} ${rowCount === 1 ? "element" : "elements"}`; + } +} + +/** + * Rows of a heap object. list/tuple/set render vertically like dict and instance + * (plan 5.2), with the index playing the role of the key. + */ +export function rowsOfHeapValue(heapValue: HeapValue): RowModel[] { + switch (heapValue.type) { + case "dict": { + const keys = asRecord(heapValue.keys); + const values = asRecord(heapValue.value); + return Object.keys(values).map((slot) => { + const value = values[slot]; + const key = keys[slot]; + return { + key: dictKeyLabel(key), + value: formatValue(value), + ref: value.type === "ref" ? value.value : undefined, + keyRef: key?.type === "ref" ? key.value : undefined, + }; + }); + } + case "instance": { + const fields = asRecord(heapValue.value); + return Object.keys(fields).map((name) => { + const value = fields[name]; + return { + key: name, + value: formatValue(value), + ref: value.type === "ref" ? value.value : undefined, + }; + }); + } + case "set": + // A set has no index, so the key column stays empty. + return heapValue.value.map((value) => ({ + key: "", + value: formatValue(value), + ref: value.type === "ref" ? value.value : undefined, + })); + default: + return heapValue.value.map((value, index) => ({ + key: `[${index}]`, + value: formatValue(value), + ref: value.type === "ref" ? value.value : undefined, + })); + } +} + +function elkNodeFor(model: NodeModel, extraOptions?: Record): ElkNode { + const ports: ElkPort[] = []; + + // Objects get one west input port so arrowheads always land in the same place + // (plan 6.1). Frames are never targets, so they do not get one. + if (model.address !== undefined) { + ports.push({ + id: inputPortId(model.id), + width: 0, + height: 0, + layoutOptions: { "elk.port.side": "WEST" }, + }); + } + + if (!model.collapsed) { + model.rows.forEach((row, index) => { + if (row.keyRef !== undefined) { + ports.push({ + id: keyPortId(model.id, index), + width: 0, + height: 0, + layoutOptions: { "elk.port.side": "EAST" }, + }); + } + if (row.ref === undefined) { + return; + } + ports.push({ + id: rowPortId(model.id, index), + width: 0, + height: 0, + layoutOptions: { "elk.port.side": "EAST" }, + }); + }); + } + + return { + id: model.id, + // Filled in by measure.ts; ELK requires them to exist. + width: 0, + height: 0, + ports, + layoutOptions: { + "elk.portConstraints": "FIXED_POS", + ...extraOptions, + }, + }; +} + +export function buildGraph( + elem: BackendTraceElem, + collapsed: ReadonlySet
= new Set() +): VizGraph { + const heap = asRecord(elem.heap); + const visible = visibleAddresses(elem, collapsed); + const models = new Map(); + const children: ElkNode[] = []; + const edges: ElkExtendedEdge[] = []; + + const addEdge = (sourcePort: string, target: Address) => { + edges.push({ + id: `edge:${sourcePort}->${target}`, + sources: [sourcePort], + targets: [inputPortId(objectNodeId(target))], + }); + }; + + // Frames, in stack order. The *last* entry is the currently executing frame; + // html-generator marked index 0 instead, which was the frame (plan 2). + const lastFrameIndex = elem.stack.length - 1; + elem.stack.forEach((frame, index) => { + const id = frameNodeId(index); + const model: NodeModel = { + id, + kind: index === lastFrameIndex ? "current-frame" : "frame", + header: frame.frameName === "" ? "Global" : frame.frameName, + rows: frame.locals.map((local) => ({ + key: local.name, + value: formatValue(local), + ref: local.type === "ref" ? local.value : undefined, + isReturn: local.name === "return", + })), + collapsed: false, + summary: "", + }; + models.set(id, model); + children.push( + // FIRST_SEPARATE, not FIRST: frames get a layer of their own, which is what the + // Frames/Objects bands assume, and it measurably halves layout time (plan 7.7). + // + // elk.position is only a ranking hint, read by semiInteractive crossing + // minimisation; the actual y still comes from node placement. Newest frame on + // top, oldest at the bottom: the stack grows in one direction either way, and + // this is the direction the barycentre heuristic already favoured, so pinning it + // costs a fraction of the crossings that pinning the reverse did (plan 7.10). + // Only the frames carry a hint; the heap column is left to the heuristic. + elkNodeFor(model, { + "elk.layered.layering.layerConstraint": "FIRST_SEPARATE", + "elk.position": `(0,${lastFrameIndex - index})`, + }) + ); + model.rows.forEach((row, rowIndex) => { + if (row.ref !== undefined && visible.has(row.ref)) { + addEdge(rowPortId(id, rowIndex), row.ref); + } + }); + }); + + // Objects, ascending address, so the order is identical across steps (plan 6.5). + const addresses = [...visible].sort((a, b) => a - b); + for (const address of addresses) { + const heapValue = heap[address]; + if (!heapValue) { + continue; + } + const id = objectNodeId(address); + const rows = rowsOfHeapValue(heapValue); + const isCollapsed = collapsed.has(address); + const model: NodeModel = { + id, + kind: heapValue.type, + header: headerOf(heapValue), + rows, + address, + collapsed: isCollapsed, + summary: summaryOf(heapValue, rows.length), + }; + models.set(id, model); + children.push(elkNodeFor(model)); + + if (isCollapsed) { + continue; + } + rows.forEach((row, rowIndex) => { + if (row.keyRef !== undefined && visible.has(row.keyRef)) { + addEdge(keyPortId(id, rowIndex), row.keyRef); + } + if (row.ref !== undefined && visible.has(row.ref)) { + addEdge(rowPortId(id, rowIndex), row.ref); + } + }); + } + + return { + graph: { + id: "root", + layoutOptions: + elem.stack.length > 1 + ? { ...LAYOUT_OPTIONS, ...FRAME_ORDER_OPTIONS } + : LAYOUT_OPTIONS, + children, + edges, + }, + nodes: models, + }; +} + +/** Addresses a heap object points at. Re-exported so callers need one import. */ +export { outgoingRefs }; diff --git a/src/programflow-visualization/main.ts b/src/programflow-visualization/main.ts index a56e0ab8..60c138d3 100644 --- a/src/programflow-visualization/main.ts +++ b/src/programflow-visualization/main.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { startBackend } from './backend/backend'; import * as FileHandler from './FileHandler'; import { Md5 } from 'ts-md5'; -import { startFrontend } from './frontend/frontend'; +import { VisualizationPanel } from './frontend/visualization_panel'; import { traceAlreadyExists } from './trace_cache'; export function getProgFlowVizCallback(context: vscode.ExtensionContext, outChannel: vscode.OutputChannel): () => Promise { @@ -29,11 +29,8 @@ export function getProgFlowVizCallback(context: vscode.ExtensionContext, outChan tracePort = startBackend(context, file, outChannel); } - const result = await startFrontend(context, outChannel, file.fsPath, fileHash, tracePort); - if (result) { - await vscode.window.showErrorMessage("Error ProgramFlow-Visualization: " + result.errorMessage); - return; - } + await VisualizationPanel.start(context, outChannel, file.fsPath, fileHash, tracePort); + } catch (e: any) { if (e instanceof Error) { outChannel.appendLine(e.stack?.toString() ?? "Error: "); diff --git a/src/programflow-visualization/reachability.ts b/src/programflow-visualization/reachability.ts new file mode 100644 index 00000000..504a603f --- /dev/null +++ b/src/programflow-visualization/reachability.ts @@ -0,0 +1,88 @@ +// Reachability filter for the visualization: decides which heap objects are visible +// for a trace step, given the set of collapsed objects. See elk-task/elk-plan.md 6.3. +// +// Pure and free of DOM/ELK dependencies so it can be unit tested directly. +import type { Address, BackendTraceElem, HeapValue, NamedValue, Value } from "./types"; + +type RefValue = { type: 'ref'; value: Address }; + +/** + * `heap`, `dict.keys`, `dict.value` and `instance.value` are declared as `Map`s in + * types.ts, but after the IPC/JSON round trip they are plain objects. Everything here + * goes through this helper instead of `Map` methods. + */ +export function asRecord(mapLike: unknown): Record { + return (mapLike ?? {}) as Record; +} + +function isRef(value: T): value is T & RefValue { + return value.type === 'ref'; +} + +export function heapEntries(heap: BackendTraceElem['heap']): Array<[Address, HeapValue]> { + return Object.entries(asRecord(heap)).map( + ([address, value]) => [Number(address), value] + ); +} + +/** Addresses referenced by a heap object: elements, dict keys and values, instance fields. */ +export function outgoingRefs(heapValue: HeapValue): Address[] { + switch (heapValue.type) { + case 'dict': { + const keys = Object.values(asRecord(heapValue.keys)); + const values = Object.values(asRecord(heapValue.value)); + return [...keys, ...values].filter(isRef).map((value) => value.value); + } + case 'instance': { + const fields = Object.values(asRecord(heapValue.value)); + return fields.filter(isRef).map((value) => value.value); + } + default: + return heapValue.value.filter(isRef).map((value) => value.value); + } +} + +/** Addresses referenced directly by a stack frame, i.e. the roots of the traversal. */ +export function rootRefs(elem: BackendTraceElem): Address[] { + return elem.stack.flatMap((frame) => + (frame.locals as Array).filter(isRef).map((local) => local.value) + ); +} + +/** + * Every heap address that should be rendered for this step. + * + * Traversal starts at the stack frames and does not follow edges *out of* a collapsed + * object - the collapsed object itself stays visible. An object therefore disappears + * only when it is exclusively downstream of a collapsed one; anything still reachable + * by another path stays. + */ +export function visibleAddresses( + elem: BackendTraceElem, + collapsed: ReadonlySet
= new Set() +): Set
{ + const heap = new Map(heapEntries(elem.heap)); + const visible = new Set
(); + const pending = rootRefs(elem).filter((address) => heap.has(address)); + + while (pending.length > 0) { + const address = pending.pop()!; + if (visible.has(address)) { + continue; + } + visible.add(address); + + // Collapsed: render the box, but nothing behind it. + if (collapsed.has(address)) { + continue; + } + + for (const next of outgoingRefs(heap.get(address)!)) { + if (!visible.has(next) && heap.has(next)) { + pending.push(next); + } + } + } + + return visible; +} diff --git a/src/programflow-visualization/trace_cache.ts b/src/programflow-visualization/trace_cache.ts index 0cc049d8..49d72871 100644 --- a/src/programflow-visualization/trace_cache.ts +++ b/src/programflow-visualization/trace_cache.ts @@ -5,6 +5,7 @@ import path = require('path'); import stringify from 'stringify-json'; import util = require('util'); import * as FileHandler from './FileHandler'; +import type { BackendTrace } from "./types"; export async function initTraceCache(context: vscode.ExtensionContext): Promise { tmp.setGracefulCleanup(); diff --git a/src/programflow-visualization/types.ts b/src/programflow-visualization/types.ts index e2528ac4..acca6adf 100644 --- a/src/programflow-visualization/types.ts +++ b/src/programflow-visualization/types.ts @@ -1,29 +1,18 @@ /** * For better readable code */ -type Try = Success | Failure; -type Success = { result: any }; -type Failure = { errorMessage: string }; - -// State Types for the Frontend -type FrontendTrace = Array; - -type FrontendTraceElem = { - lineNumber: number, // 1-based - stackHTML: string, - heapHTML: string, - filename: string, - outputState: string, -}; +export type Try = Success | Failure; +export type Success = { result: any }; +export type Failure = { errorMessage: string }; // ############################################################################################ // State Types for the Backend -type PartialBackendTrace = { +export type PartialBackendTrace = { trace: BackendTrace; complete: boolean; }; -type BackendTrace = Array; -type BackendTraceElem = { +export type BackendTrace = Array; +export type BackendTraceElem = { line: number; filePath: string, stack: Array; @@ -32,9 +21,9 @@ type BackendTraceElem = { traceback: string | undefined; }; -type Address = number; +export type Address = number; -type Value = +export type Value = | { type: 'int'; value: number } | { type: 'float'; value: number } | { type: 'str'; value: string } @@ -44,17 +33,17 @@ type Value = | { type: 'function'; value: string } | { type: 'ref'; value: Address }; -type NamedValue = Value & { +export type NamedValue = Value & { name: string; }; -type StackElem = { +export type StackElem = { frameName: string; locals: Array; }; -type HeapValue = +export type HeapValue = | { type: 'list'; value: Array } | { type: 'tuple'; value: Array } | { type: 'set'; value: Array } diff --git a/src/programflow-visualization/web/elk-view.ts b/src/programflow-visualization/web/elk-view.ts new file mode 100644 index 00000000..b00e8200 --- /dev/null +++ b/src/programflow-visualization/web/elk-view.ts @@ -0,0 +1,123 @@ +// Ties the pipeline together: model -> measure -> ELK -> DOM. +// See elk-task/elk-plan.md 6. +import ELK from "elkjs/lib/elk.bundled.js"; +import type { ElkNode } from "elkjs/lib/elk-api"; +import type { Address, BackendTraceElem } from "../types"; +import { buildGraph, type NodeModel } from "../graph-model"; +import { ensureFontsReady, measureGraph } from "./measure"; +import { renderGraph, type RenderOptions } from "./graph-renderer"; + +// No workerUrl: elkjs then uses its in-process fake worker. A real Web Worker +// would be blocked anyway, the webview CSP has no worker-src (plan 2). +let elk: InstanceType | null = null; + +function elkInstance(): InstanceType { + if (!elk) { + elk = new ELK(); + } + return elk; +} + +// Layout is async, and steps can be requested faster than they finish. Only the +// most recent request is allowed to touch the DOM. +let renderToken = 0; + +type CacheEntry = { laidOut: ElkNode; models: Map }; + +/** + * Stepping back and forth re-visits the same graphs constantly, and layout is the + * expensive part (plan 6.5). Bounded, or a long trace keeps every step ever visited. + */ +const MAX_CACHED_LAYOUTS = 40; +const layoutCache = new Map(); + +/** + * Text metrics are baked into every cached layout, so anything that changes them -- a + * theme switch, an editor font-size change -- invalidates all of it. + */ +export function clearLayoutCache(): void { + layoutCache.clear(); +} + +function cacheKey(step: string, collapsed: ReadonlySet
): string { + return `${step}|${[...collapsed].sort((a, b) => a - b).join(",")}`; +} + +function remember(key: string, entry: CacheEntry): void { + layoutCache.set(key, entry); + while (layoutCache.size > MAX_CACHED_LAYOUTS) { + const oldest = layoutCache.keys().next(); + if (oldest.done) { + break; + } + layoutCache.delete(oldest.value); + } +} + +/** + * The first layout pays ~330 ms of JIT warm-up (spike, plan 6.5). Doing it on a + * throwaway graph at start-up keeps that cost out of the first real step. + */ +export async function prewarm(): Promise { + await ensureFontsReady(); + try { + await elkInstance().layout({ + id: "prewarm", + children: [ + { id: "a", width: 10, height: 10 }, + { id: "b", width: 10, height: 10 }, + ], + edges: [{ id: "e", sources: ["a"], targets: ["b"] }], + }); + } catch { + // Warm-up only; a failure here says nothing about real layouts. + } +} + +export type StepRenderOptions = RenderOptions & { + /** Identifies the step; together with the collapsed set it keys the layout cache. */ + step: string; + /** Bounds of the drawn graph, reported after every render so the caller can fit. */ + onBounds?: (width: number, height: number) => void; +}; + +export async function renderStep( + container: HTMLElement, + elem: BackendTraceElem, + collapsed: ReadonlySet
, + options: StepRenderOptions +): Promise { + const token = ++renderToken; + const key = cacheKey(options.step, collapsed); + + const cached = layoutCache.get(key); + if (cached) { + // Re-insert so this becomes the most recently used entry. + layoutCache.delete(key); + layoutCache.set(key, cached); + paint(container, cached, options); + return; + } + + const viz = buildGraph(elem, collapsed); + measureGraph(viz); + const laidOut = await elkInstance().layout(viz.graph); + if (token !== renderToken) { + return; + } + const entry: CacheEntry = { laidOut, models: viz.nodes }; + remember(key, entry); + paint(container, entry, options); +} + +function paint( + container: HTMLElement, + entry: CacheEntry, + options: StepRenderOptions +): void { + renderGraph(container, entry.laidOut, entry.models, options); + options.onBounds?.( + parseFloat(container.style.width) || 0, + parseFloat(container.style.height) || 0 + ); +} diff --git a/src/programflow-visualization/web/example-trace-content.js b/src/programflow-visualization/web/example-trace-content.js new file mode 100644 index 00000000..0213b1cf --- /dev/null +++ b/src/programflow-visualization/web/example-trace-content.js @@ -0,0 +1,426 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// placeholder trace for design development +window.__PROGRAMFLOW_TRACE__ = { + complete: true, + trace: [ + { + "line": 1, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [] + } + ], + "heap": {}, + "stdout": "" + }, + { + "line": 2, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [] + } + ], + "heap": {}, + "stdout": "" + }, + { + "line": 5, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [] + } + ], + "heap": {}, + "stdout": "" + }, + { + "line": 8, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + } + ] + } + ], + "heap": {}, + "stdout": "" + }, + { + "line": 11, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + } + ] + } + ], + "heap": {}, + "stdout": "" + }, + { + "line": 12, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + }, + { + "type": "ref", + "value": 0, + "name": "obj1" + } + ] + } + ], + "heap": { + "0": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + } + }, + "stdout": "" + }, + { + "line": 13, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + }, + { + "type": "ref", + "value": 0, + "name": "obj1" + } + ] + } + ], + "heap": { + "0": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + } + }, + "stdout": "baz value\n" + }, + { + "line": 9, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + }, + { + "type": "ref", + "value": 0, + "name": "obj1" + } + ] + }, + { + "frameName": "generate_bar", + "locals": [] + } + ], + "heap": { + "0": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + } + }, + "stdout": "baz value\n" + }, + { + "line": 9, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + }, + { + "type": "ref", + "value": 0, + "name": "obj1" + } + ] + }, + { + "frameName": "generate_bar", + "locals": [ + { + "type": "ref", + "value": 1, + "name": "return" + } + ] + } + ], + "heap": { + "0": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + }, + "1": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + } + }, + "stdout": "baz value\n" + }, + { + "line": 14, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + }, + { + "type": "ref", + "value": 0, + "name": "obj1" + }, + { + "type": "ref", + "value": 1, + "name": "obj2" + } + ] + } + ], + "heap": { + "0": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + }, + "1": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + } + }, + "stdout": "baz value\n" + }, + { + "line": 16, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + }, + { + "type": "ref", + "value": 0, + "name": "obj1" + }, + { + "type": "ref", + "value": 1, + "name": "obj2" + } + ] + } + ], + "heap": { + "0": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + }, + "1": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + } + }, + "stdout": "baz value\nbaz value\n" + }, + { + "line": 18, + "filePath": "wyppSimple.py", + "stack": [ + { + "frameName": "", + "locals": [ + { + "type": "type", + "value": "", + "name": "Bar" + }, + { + "type": "function", + "value": "", + "name": "generate_bar" + }, + { + "type": "ref", + "value": 0, + "name": "obj1" + }, + { + "type": "ref", + "value": 1, + "name": "obj2" + } + ] + } + ], + "heap": { + "0": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + }, + "1": { + "type": "instance", + "value": { + "baz": { + "type": "str", + "value": "baz value" + } + }, + "name": "Bar" + } + }, + "stdout": "baz value\nbaz value\n" + } +] +}; diff --git a/src/programflow-visualization/web/graph-renderer.ts b/src/programflow-visualization/web/graph-renderer.ts new file mode 100644 index 00000000..ae821740 --- /dev/null +++ b/src/programflow-visualization/web/graph-renderer.ts @@ -0,0 +1,248 @@ +// Draws a laid-out graph. See elk-task/elk-plan.md 6.3. +// +// Builds the node elements from the same `renderNode` measure.ts used, then places them at +// the coordinates ELK computed. Edges go into one SVG overlay behind the nodes. +import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api"; +import type { NodeModel } from "../graph-model"; +import { headerElement, NODE_CLASS, renderNode } from "./node-view"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const MARGIN = 24; +/** Room above the nodes for the "Frames" / "Objects" captions. */ +const BAND_HEIGHT = 28; + +/** + * Last seen pointer position, in client coordinates. Collapsing a node re-lays out the + * graph under a stationary cursor, and a stationary cursor fires no mouseenter on the + * freshly built elements, so each render has to re-derive the hover itself. + */ +let pointer: { x: number; y: number } | undefined; +let pointerTracked = false; + +function trackPointer(): void { + if (pointerTracked) { + return; + } + pointerTracked = true; + window.addEventListener( + "pointermove", + (event: PointerEvent) => { + pointer = { x: event.clientX, y: event.clientY }; + }, + { passive: true, capture: true } + ); + // A pointer that has left the window is not over anything. pointerleave does not + // bubble, so this has to sit on the element it is targeted at, not on window. + document.documentElement.addEventListener("pointerleave", () => { + pointer = undefined; + }); +} + +export type RenderOptions = { + /** Called when a collapsible node header is activated. */ + onToggle?: (address: number) => void; +}; + +function pointsOf(edge: ElkExtendedEdge): Array<{ x: number; y: number }> { + const section = edge.sections?.[0]; + if (!section) { + return []; + } + return [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]; +} + +function arrowMarker(): SVGMarkerElement { + const marker = document.createElementNS(SVG_NS, "marker"); + marker.setAttribute("id", "elk-arrowhead"); + marker.setAttribute("viewBox", "0 0 8 8"); + marker.setAttribute("refX", "7"); + marker.setAttribute("refY", "4"); + marker.setAttribute("markerWidth", "7"); + marker.setAttribute("markerHeight", "7"); + marker.setAttribute("orient", "auto-start-reverse"); + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", "M 0 0 L 8 4 L 0 8 z"); + path.setAttribute("class", "elk-arrowhead"); + marker.append(path); + return marker; +} + +function band( + label: string, + modifier: string, + left: number, + width: number +): HTMLElement { + const element = document.createElement("div"); + element.className = `elk-band ${modifier}`; + element.textContent = label; + element.style.left = `${left}px`; + element.style.top = `${MARGIN}px`; + element.style.height = `${BAND_HEIGHT}px`; + element.style.width = `${Math.max(width, 0)}px`; + return element; +} + +export function renderGraph( + container: HTMLElement, + laidOut: ElkNode, + models: Map, + options: RenderOptions = {} +): void { + trackPointer(); + container.textContent = ""; + container.classList.add("elk-canvas"); + // Wiping the children destroys the hovered node without ever firing its mouseleave, + // so the dim-everything-else class would otherwise survive with nothing highlighted. + container.classList.remove("elk-dimming"); + + const children = laidOut.children ?? []; + const portToNode = new Map(); + for (const child of children) { + for (const port of child.ports ?? []) { + portToNode.set(port.id, child.id); + } + } + + const width = Math.max( + ...children.map((child) => (child.x ?? 0) + (child.width ?? 0)), + 0 + ); + const height = Math.max( + ...children.map((child) => (child.y ?? 0) + (child.height ?? 0)), + 0 + ); + container.style.width = `${width + 2 * MARGIN}px`; + container.style.height = `${height + BAND_HEIGHT + 2 * MARGIN}px`; + + const offsetY = MARGIN + BAND_HEIGHT; + + // Edges first: the SVG sits behind the nodes. + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "elk-edges"); + svg.setAttribute("width", `${width + 2 * MARGIN}`); + svg.setAttribute("height", `${height + BAND_HEIGHT + 2 * MARGIN}`); + const defs = document.createElementNS(SVG_NS, "defs"); + defs.append(arrowMarker()); + svg.append(defs); + + const edgesByNode = new Map(); + const track = (nodeId: string | undefined, path: SVGPathElement) => { + if (!nodeId) { + return; + } + const list = edgesByNode.get(nodeId); + if (list) { + list.push(path); + } else { + edgesByNode.set(nodeId, [path]); + } + }; + + for (const edge of (laidOut.edges ?? []) as ElkExtendedEdge[]) { + const points = pointsOf(edge); + if (points.length < 2) { + continue; + } + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute( + "d", + points + .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x + MARGIN} ${point.y + offsetY}`) + .join(" ") + ); + path.setAttribute("class", "elk-edge"); + path.setAttribute("marker-end", "url(#elk-arrowhead)"); + svg.append(path); + track(portToNode.get(edge.sources[0]), path); + track(portToNode.get(edge.targets[0]), path); + } + container.append(svg); + + // Hovering a node highlights everything it is connected to (plan 6.4). One shared + // "which node is active" state, so that a re-render can restore it without a gesture. + let active: HTMLElement | undefined; + + const paintActive = (element: HTMLElement, on: boolean) => { + element.classList.toggle("elk-node-active", on); + for (const path of edgesByNode.get(element.dataset.nodeId ?? "") ?? []) { + path.classList.toggle("elk-edge-active", on); + } + }; + + const setActive = (element: HTMLElement | undefined) => { + if (active === element) { + return; + } + if (active) { + paintActive(active, false); + } + active = element; + if (active) { + paintActive(active, true); + } + container.classList.toggle("elk-dimming", active !== undefined); + }; + + // Nodes. + let framesRight = Number.NEGATIVE_INFINITY; + let objectsLeft = Number.POSITIVE_INFINITY; + + for (const child of children) { + const model = models.get(child.id); + if (!model) { + continue; + } + const element = renderNode(model); + element.dataset.nodeId = child.id; + element.style.left = `${(child.x ?? 0) + MARGIN}px`; + element.style.top = `${(child.y ?? 0) + offsetY}px`; + element.style.width = `${child.width ?? 0}px`; + + if (model.address === undefined) { + framesRight = Math.max(framesRight, (child.x ?? 0) + (child.width ?? 0)); + } else { + objectsLeft = Math.min(objectsLeft, child.x ?? 0); + const header = headerElement(element); + const address = model.address; + const toggle = () => options.onToggle?.(address); + header?.addEventListener("click", toggle); + header?.addEventListener("keydown", (event) => { + const key = (event as KeyboardEvent).key; + if (key === "Enter" || key === " ") { + event.preventDefault(); + toggle(); + } + }); + } + + // Hovering a node highlights everything it is connected to (plan 6.4). + element.addEventListener("mouseenter", () => { + setActive(element); + }); + element.addEventListener("mouseleave", () => { + setActive(undefined); + }); + + container.append(element); + } + + // Column captions, derived from where the nodes actually ended up: there are no + // container nodes to hang them off (plan 4). + if (framesRight > Number.NEGATIVE_INFINITY) { + container.append(band("Frames", "elk-band-frames", MARGIN, framesRight)); + } + if (objectsLeft < Number.POSITIVE_INFINITY) { + container.append( + band("Objects", "elk-band-objects", objectsLeft + MARGIN, width - objectsLeft) + ); + } + + // Whatever the cursor is sitting on now is hovered, even though it never moved. + // elementFromPoint flushes layout, so the positions set above are already in effect. + if (pointer) { + const hit = document.elementFromPoint(pointer.x, pointer.y); + const node = hit?.closest(`.${NODE_CLASS}`) ?? undefined; + setActive(node && container.contains(node) ? node : undefined); + } +} diff --git a/src/programflow-visualization/web/index.html b/src/programflow-visualization/web/index.html new file mode 100644 index 00000000..30566815 --- /dev/null +++ b/src/programflow-visualization/web/index.html @@ -0,0 +1,91 @@ + + + + + + + + + + Code Visualization + + + +
+ + +
+
+ + +
+ + + + +
+
+
+ +
+
+
+ +
+ +
+

Step 

+

0

+

/?

+
+ +
+ + + + +
+
+ +
+

+    
+
+ + + + + + + + + + + diff --git a/src/programflow-visualization/web/measure.ts b/src/programflow-visualization/web/measure.ts new file mode 100644 index 00000000..01a81426 --- /dev/null +++ b/src/programflow-visualization/web/measure.ts @@ -0,0 +1,133 @@ +// Offscreen measurement. See elk-task/elk-plan.md 6.2. +// +// ELK needs a width and a height for every node before it can lay anything out, and our +// nodes are HTML, so the browser has to be asked. The elements built here are throwaway; +// graph-renderer.ts builds its own from the same `renderNode`, which is what keeps the +// measured size and the drawn size in agreement. +import type { ElkNode } from "elkjs/lib/elk-api"; +import type { VizGraph } from "../graph-model"; +import { inputPortId, keyPortId, rowPortId } from "../graph-model"; +import { renderNode, headerElement, rowElements } from "./node-view"; + +const HOST_ID = "elk-measure-host"; + +/** Wide enough that nothing wraps for want of room; nodes size to their content. */ +const HOST_WIDTH_PX = 4000; + +function measureHost(): HTMLElement { + const existing = document.getElementById(HOST_ID); + if (existing) { + existing.textContent = ""; + return existing; + } + const host = document.createElement("div"); + host.id = HOST_ID; + // visibility: hidden, never display: none -- a display:none subtree has no + // layout boxes at all and every measurement comes back zero. + host.style.cssText = [ + "position: absolute", + "visibility: hidden", + "pointer-events: none", + "left: -20000px", + "top: 0", + `width: ${HOST_WIDTH_PX}px`, + ].join("; "); + document.body.append(host); + return host; +} + +/** + * Web fonts change text metrics, so anything measured before they load is wrong. + * Await this once before the first layout. + */ +export function ensureFontsReady(): Promise { + const fonts = (document as Document & { fonts?: { ready: Promise } }).fonts; + return fonts ? fonts.ready.then(() => undefined) : Promise.resolve(); +} + +/** + * Fills width/height on every node of `viz.graph` and positions its ports. + */ +export function measureGraph(viz: VizGraph): void { + const host = measureHost(); + const elements = new Map(); + const children: ElkNode[] = viz.graph.children ?? []; + + // Write phase: build the whole subtree first, so the reads below trigger a + // single layout pass instead of one per node. + for (const child of children) { + const model = viz.nodes.get(child.id); + if (!model) { + continue; + } + const element = renderNode(model); + elements.set(child.id, element); + host.append(element); + } + + // Read phase. + for (const child of children) { + const element = elements.get(child.id); + if (!element) { + continue; + } + const nodeBox = element.getBoundingClientRect(); + const width = Math.ceil(nodeBox.width); + const height = Math.ceil(nodeBox.height); + child.width = width; + child.height = height; + + const model = viz.nodes.get(child.id); + const rowBoxes = rowElements(element).map((row) => { + const box = row.getBoundingClientRect(); + return { top: box.top - nodeBox.top, height: box.height }; + }); + const header = headerElement(element); + const headerBox = header?.getBoundingClientRect(); + const headerCenter = headerBox + ? headerBox.top + headerBox.height / 2 - nodeBox.top + : height / 2; + + /** + * A dict row whose key is a reference has two outgoing edges. Splitting the row + * into thirds keeps their start points apart; a row with one edge keeps the + * centre, which is where the eye expects it. + */ + const portY = (rowIndex: number, isKey: boolean, split: boolean): number => { + const box = rowBoxes[rowIndex]; + if (!box) { + return Math.round(height / 2); + } + if (!split) { + return Math.round(box.top + box.height / 2); + } + return Math.round(box.top + (isKey ? box.height / 3 : (2 * box.height) / 3)); + }; + + for (const port of child.ports ?? []) { + if (port.id === inputPortId(child.id)) { + // West side, level with the header, so the arrowhead points at the title. + port.x = 0; + port.y = Math.round(headerCenter); + continue; + } + port.x = width; + const rows = model?.rows ?? []; + const keyIndex = rows.findIndex( + (_, index) => keyPortId(child.id, index) === port.id + ); + const rowIndex = + keyIndex >= 0 + ? keyIndex + : rows.findIndex((_, index) => rowPortId(child.id, index) === port.id); + if (rowIndex < 0) { + port.y = Math.round(height / 2); + continue; + } + const row = rows[rowIndex]; + port.y = portY(rowIndex, keyIndex >= 0, row.keyRef !== undefined && row.ref !== undefined); + } + } + + host.textContent = ""; +} diff --git a/src/programflow-visualization/web/node-view.ts b/src/programflow-visualization/web/node-view.ts new file mode 100644 index 00000000..bc02e806 --- /dev/null +++ b/src/programflow-visualization/web/node-view.ts @@ -0,0 +1,76 @@ +// The single source of node markup. See elk-task/elk-plan.md 6. +// +// measure.ts renders these offscreen to obtain sizes, graph-renderer.ts renders +// them for real. Both must call renderNode, never build markup of their own, or +// measured sizes stop matching what is drawn. +import type { NodeModel, RowModel } from "../graph-model"; + +export const NODE_CLASS = "elk-node"; +export const HEADER_CLASS = "elk-node-header"; +export const ROWS_CLASS = "elk-node-rows"; +export const ROW_CLASS = "elk-row"; + +function div(className: string, text?: string): HTMLDivElement { + const element = document.createElement("div"); + element.className = className; + if (text !== undefined) { + // textContent, not innerHTML: trace values are program data. + element.textContent = text; + } + return element; +} + +function renderRow(row: RowModel): HTMLElement { + const element = div(ROW_CLASS); + const key = div("elk-row-key" + (row.isReturn ? " return-value" : ""), row.key); + const value = div("elk-row-value", row.value); + if (row.ref !== undefined) { + element.classList.add("elk-row-ref"); + } + if (row.keyRef !== undefined) { + key.classList.add("elk-key-ref"); + } + element.append(key, value); + return element; +} + +export function renderNode(model: NodeModel): HTMLElement { + const node = div(`${NODE_CLASS} elk-${model.kind}`); + node.dataset.nodeId = model.id; + + const header = div(HEADER_CLASS); + const collapsible = model.address !== undefined; + if (collapsible) { + // Discoverable and keyboard-operable (plan 6.3). + header.classList.add("elk-collapsible"); + header.setAttribute("role", "button"); + header.setAttribute("tabindex", "0"); + header.setAttribute("aria-expanded", String(!model.collapsed)); + header.append(div("elk-caret", model.collapsed ? "\u25B8" : "\u25BE")); + } + header.append(div("elk-node-title", model.header)); + node.append(header); + + const rows = div(ROWS_CLASS); + if (model.collapsed) { + node.classList.add("elk-collapsed"); + rows.append(div(`${ROW_CLASS} elk-summary`, model.summary)); + } else { + for (const row of model.rows) { + rows.append(renderRow(row)); + } + } + node.append(rows); + + return node; +} + +/** Row elements of a rendered node, in model order. */ +export function rowElements(node: HTMLElement): HTMLElement[] { + const rows = node.querySelector(`.${ROWS_CLASS}`); + return rows ? (Array.from(rows.children) as HTMLElement[]) : []; +} + +export function headerElement(node: HTMLElement): HTMLElement | null { + return node.querySelector(`.${HEADER_CLASS}`); +} diff --git a/src/programflow-visualization/web/pan-zoom.ts b/src/programflow-visualization/web/pan-zoom.ts new file mode 100644 index 00000000..9b1a5ef5 --- /dev/null +++ b/src/programflow-visualization/web/pan-zoom.ts @@ -0,0 +1,206 @@ +// Pan and zoom for the visualization canvas. See elk-task/elk-plan.md 6.5. +// +// Everything ELK produced lives in one absolutely positioned canvas, so a single +// `transform` on that element moves the node divs and the SVG edge overlay together -- +// no re-layout, no coordinate recomputation, and GPU-composited. + +/** Discrete steps: fractional scales make text look soft. */ +const ZOOM_STEPS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3]; + +/** Pointer movement beyond this counts as a pan, and the trailing click is swallowed. */ +const DRAG_THRESHOLD_PX = 3; + +const FIT_PADDING_PX = 16; + +export type PanZoom = { + /** Scale the content to fit the viewport and centre it, and re-enable auto-fitting. */ + fit(width: number, height: number): void; + /** + * Same, but does nothing once the user has panned or zoomed. Until then every render + * re-frames the graph, which matters because a trace grows from one node to dozens. + */ + autoFit(width: number, height: number): void; + /** One step up the zoom ladder, anchored on the middle of the viewport. */ + zoomIn(): void; + /** One step down the zoom ladder, anchored on the middle of the viewport. */ + zoomOut(): void; +}; + +/** Events on the floating toolbar are UI, not canvas gestures. */ +function isUi(target: EventTarget | null): boolean { + return target instanceof Element && target.closest("[data-elk-ui]") !== null; +} + +function nearestStep(scale: number): number { + return ZOOM_STEPS.reduce((best, step) => + Math.abs(step - scale) < Math.abs(best - scale) ? step : best + ); +} + +function stepFrom(scale: number, direction: 1 | -1): number { + const index = ZOOM_STEPS.indexOf(nearestStep(scale)); + return ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, Math.max(0, index + direction))]; +} + +export function attachPanZoom(viewport: HTMLElement, canvas: HTMLElement): PanZoom { + let scale = 1; + let translateX = 0; + let translateY = 0; + /** Set once the user takes the view over, which switches auto-fitting off. */ + let touched = false; + /** Size of the last graph drawn, so a resize can re-fit without being told again. */ + let lastWidth = 0; + let lastHeight = 0; + + const apply = () => { + canvas.style.transformOrigin = "0 0"; + canvas.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`; + }; + + /** + * One step along the ladder, keeping the graph point at (anchorX, anchorY) -- both + * relative to the viewport -- exactly where it is. + */ + const zoomStep = (direction: 1 | -1, anchorX: number, anchorY: number) => { + const next = stepFrom(scale, direction); + if (next === scale) { + return; + } + touched = true; + translateX = anchorX - ((anchorX - translateX) / scale) * next; + translateY = anchorY - ((anchorY - translateY) / scale) * next; + scale = next; + apply(); + }; + + const zoomFromCentre = (direction: 1 | -1) => { + const box = viewport.getBoundingClientRect(); + zoomStep(direction, box.width / 2, box.height / 2); + }; + + viewport.addEventListener( + "wheel", + (event: WheelEvent) => { + if (isUi(event.target)) { + return; + } + event.preventDefault(); + const box = viewport.getBoundingClientRect(); + zoomStep( + event.deltaY < 0 ? 1 : -1, + event.clientX - box.left, + event.clientY - box.top + ); + }, + { passive: false } + ); + + let pointerId: number | null = null; + let startX = 0; + let startY = 0; + let dragged = false; + + const onMove = (event: PointerEvent) => { + if (pointerId !== event.pointerId) { + return; + } + const nextX = event.clientX - startX; + const nextY = event.clientY - startY; + if ( + !dragged && + Math.abs(nextX - translateX) < DRAG_THRESHOLD_PX && + Math.abs(nextY - translateY) < DRAG_THRESHOLD_PX + ) { + return; + } + dragged = true; + touched = true; + viewport.classList.add("elk-panning"); + translateX = nextX; + translateY = nextY; + apply(); + }; + + const onUp = (event: PointerEvent) => { + if (pointerId !== event.pointerId) { + return; + } + pointerId = null; + viewport.classList.remove("elk-panning"); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + + viewport.addEventListener("pointerdown", (event: PointerEvent) => { + if (event.button !== 0 || isUi(event.target)) { + return; + } + pointerId = event.pointerId; + startX = event.clientX - translateX; + startY = event.clientY - translateY; + dragged = false; + // Listening on window, not the viewport, keeps a drag alive when the cursor + // leaves the canvas -- same effect as pointer capture, without the capture API. + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }); + + // A pan that ends on a node header must not also toggle it. + viewport.addEventListener( + "click", + (event: MouseEvent) => { + if (dragged) { + event.stopPropagation(); + event.preventDefault(); + dragged = false; + } + }, + true + ); + + const fit = (width: number, height: number) => { + const box = viewport.getBoundingClientRect(); + if (width <= 0 || height <= 0 || box.width <= 0 || box.height <= 0) { + return; + } + lastWidth = width; + lastHeight = height; + const raw = Math.min( + (box.width - FIT_PADDING_PX) / width, + (box.height - FIT_PADDING_PX) / height + ); + // Never zoom *in* to fit: a two-node graph blown up to full width is unreadable. + scale = raw >= 1 ? 1 : nearestStep(raw); + translateX = Math.max(0, (box.width - width * scale) / 2); + translateY = Math.max(0, (box.height - height * scale) / 2); + apply(); + }; + + // Resizing the panel changes what "fits", so re-frame while auto-fitting is still on. + new ResizeObserver(() => { + if (!touched) { + fit(lastWidth, lastHeight); + } + }).observe(viewport); + + return { + fit(width, height) { + // An explicit Fit is also a request to go back to being framed automatically. + touched = false; + fit(width, height); + }, + autoFit(width, height) { + if (!touched) { + fit(width, height); + } + }, + zoomIn() { + zoomFromCentre(1); + }, + zoomOut() { + zoomFromCentre(-1); + }, + }; +} diff --git a/src/programflow-visualization/web/tsconfig.json b/src/programflow-visualization/web/tsconfig.json new file mode 100644 index 00000000..ad34f560 --- /dev/null +++ b/src/programflow-visualization/web/tsconfig.json @@ -0,0 +1,13 @@ +// Configure TypeScript compilation for the webview-specific source files +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + // es2019 for Array.prototype.flatMap, used by ../reachability.ts. + "lib": ["es2019", "dom"], + "types": [], + // elkjs ships a .d.ts that does not survive strict checking. + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["../../../node_modules", "../../../.vscode-test"] +} \ No newline at end of file diff --git a/src/programflow-visualization/web/vscode-host-adapter.ts b/src/programflow-visualization/web/vscode-host-adapter.ts new file mode 100644 index 00000000..413a6e80 --- /dev/null +++ b/src/programflow-visualization/web/vscode-host-adapter.ts @@ -0,0 +1,28 @@ +// Bridge messages between the VS Code host API and webview custom events +declare const acquireVsCodeApi: undefined | (() => { postMessage: (msg: any) => void }); + +type HighlightMsg = { command: "highlight"; filePath: string; line: number }; + +const vscode = typeof acquireVsCodeApi === "function" + ? acquireVsCodeApi() + : { postMessage: (_: any) => {} }; + +window.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + if (!msg?.command) {return;} + + switch (msg.command) { + case "reset": + window.dispatchEvent(new CustomEvent("programflow:reset", { detail: msg })); + break; + case "append": + window.dispatchEvent(new CustomEvent("programflow:append", { detail: msg })); + break; + } +}); + +window.addEventListener("programflow:highlight", (e: Event) => { + const ce = e as CustomEvent<{ filePath: string; line: number }>; + const msg: HighlightMsg = { command: "highlight", ...ce.detail }; + vscode.postMessage(msg); +}); diff --git a/src/programflow-visualization/web/webview.css b/src/programflow-visualization/web/webview.css new file mode 100644 index 00000000..2136af38 --- /dev/null +++ b/src/programflow-visualization/web/webview.css @@ -0,0 +1,394 @@ +body { + height: 100vh; + width: 100vw; + /* The UA default of 8px would push 100vw past the window edge, which now matters + because the view controls are pinned to the right. VS Code zeroes it anyway. */ + margin: 0; + overflow: auto; + display: flex; + flex-direction: column; +} + +* { + box-sizing: border-box; +} + +/* .row/.column set display, which would otherwise beat the UA [hidden] rule. */ +[hidden] { + display: none !important; +} + +.floating-right { + float: right; + width: 65%; +} + +.floating-left { + float: left; + width: 35%; +} + +.scrollable { + overflow: auto; +} + +#viz { + flex-grow: 1; + padding-top: 10px; +} + +#bottom-area { + min-height: 160px; + max-height: 160px; + padding-bottom: 10px; +} + +/* The two columns are 35%/65% of a flex row, so a narrow panel squeezes the step + controls until they overflow their column and land under the stdout pane, which + would otherwise swallow the clicks. Lift them into a higher stacking level so they + stay pressable even while overlapping. */ +#bottom-area .floating-left { + position: relative; + z-index: 1; +} + +#stdout-log { + height: 100%; + border: 1.5px; + border-style: solid; + border-color: grey; +} + +.margin-vertical { + margin-top: 3px; + margin-bottom: 3px; +} + +.margin-horizontal { + margin-left: 3px; + margin-right: 3px; +} + +.row { + display: flex; + flex-direction: row; +} + +.column { + display: flex; + flex-direction: column; +} + +.traceback-text { + color: red; +} + +#bottom-area button { + line-height: 18px; +} + +/* ------------------------------------------------------------------ */ +/* ELK-based visualization. See elk-task/elk-plan.md 6.4. */ +/* ------------------------------------------------------------------ */ + +/* Every colour below this line is named here and nowhere else. Inside VS Code the + --vscode-* variables win; the literals are only ever used by the browser dev mode + (index.web.html), which has no theme to inherit from. + + --vscode-charts-* is the one palette VS Code exposes for "several things that must + look different from each other", which is exactly what the node kinds need. */ +:root { + --wypp-node-bg: var(--vscode-editorWidget-background, #252526); + --wypp-node-fg: var(--vscode-editorWidget-foreground, #ccc); + --wypp-node-border: var(--vscode-panel-border, #3c3c3c); + --wypp-header-bg: var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.25)); + --wypp-row-alt-bg: rgba(128, 128, 128, 0.07); + --wypp-focus: var(--vscode-focusBorder, #4d64ff); + --wypp-edge: var(--vscode-charts-lines, #808080); + --wypp-muted: var(--vscode-descriptionForeground, #9d9d9d); + + /* Node kinds. */ + --wypp-accent-frame: var(--vscode-descriptionForeground, #9d9d9d); + --wypp-accent-current-frame: var(--vscode-focusBorder, #4d64ff); + --wypp-accent-instance: var(--vscode-charts-blue, #4e95d9); + --wypp-accent-list: var(--vscode-charts-green, #89d185); + --wypp-accent-tuple: var(--vscode-charts-purple, #b180d7); + --wypp-accent-dict: var(--vscode-charts-orange, #d18616); + --wypp-accent-set: var(--vscode-charts-yellow, #cca700); + + /* The blue wash over the Frames column. Deliberately a literal rgba rather than a + --vscode-* token: it is layered *over* the theme's own node background, so it has + to be translucent, and no theme token carries an alpha channel. Keeping the alpha + low leaves the theme's own foreground colour readable in light and dark alike. */ + --wypp-frame-bg: rgba(77, 140, 217, 0.14); + --wypp-frame-header-bg: rgba(77, 140, 217, 0.28); + + /* High-contrast themes define --vscode-contrastBorder and expect it to be honoured; + everywhere else it is empty and the fallback applies. */ + --wypp-outline: var(--vscode-contrastBorder, var(--wypp-node-border)); +} + +/* The viewport clips and owns the gestures; only #elk-canvas is transformed, so the + step controls and the stdout pane never scale. */ +#elk-viewport { + flex-grow: 1; + /* Without this the flex default min-height:auto lets the canvas stretch #viz. */ + min-height: 0; + position: relative; + overflow: hidden; + cursor: grab; + /* Stop the browser from claiming wheel/drag before the handlers see them. */ + touch-action: none; + overscroll-behavior: contain; +} + +#elk-viewport.elk-panning { + cursor: grabbing; +} + +/* Floating view controls, pinned to the top right of the viewport. A sibling of + #elk-canvas rather than a child, so the canvas transform never scales them. */ +.elk-controls { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid var(--wypp-outline); + border-radius: 4px; + background-color: var(--wypp-node-bg); + /* The viewport shows a grab cursor; the toolbar is not draggable. */ + cursor: default; +} + +.elk-controls button { + display: flex; + align-items: center; + justify-content: center; + height: 24px; + padding: 0 8px; + line-height: 1; + white-space: nowrap; +} + +/* Square and unpadded, so the icon gets the whole content box. Scoped to .elk-controls + because the rule above is more specific than a bare .elk-icon-button would be. */ +.elk-controls .elk-icon-button { + width: 24px; + padding: 0; +} + +/* The icons are inline SVG stroked in currentColor, so they follow the button text. + flex: none stops the flex container from shrinking them below their intrinsic size. */ +.elk-controls .elk-icon-button svg { + display: block; + flex: none; +} + +.elk-canvas { + position: relative; +} + +.elk-edges { + position: absolute; + left: 0; + top: 0; + /* The SVG spans the canvas and sits behind the nodes. */ + pointer-events: none; +} + +.elk-band { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 11px; + color: var(--wypp-muted); + border-bottom: 1px solid var(--wypp-node-border); +} + +/* Matches the tint on the frame nodes below, so the whole left column reads as one. */ +.elk-band-frames { + background-color: var(--wypp-frame-bg); +} + +.elk-node { + position: absolute; + /* Shrink to content, but never so wide that one long value dominates. */ + width: max-content; + min-width: 120px; + max-width: 280px; + border: 1px solid var(--wypp-outline); + /* Overwritten per kind below; declared here so every node reserves the same width. */ + border-left: 4px solid var(--wypp-muted); + border-radius: 4px; + background-color: var(--wypp-node-bg); + color: var(--wypp-node-fg); + font-size: 13px; + overflow: hidden; +} + +.elk-node-header { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 6px; + font-weight: 600; + background-color: var(--wypp-header-bg); +} + +.elk-collapsible { + cursor: pointer; +} + +.elk-collapsible:focus-visible { + outline: 1px solid var(--wypp-focus); + outline-offset: -1px; +} + +.elk-caret { + flex: 0 0 auto; + color: var(--wypp-muted); +} + +.elk-node-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Table layout keeps the key column aligned across rows while each row keeps a + box of its own -- measure.ts needs that box to place the row's port. */ +.elk-node-rows { + display: table; + width: 100%; +} + +.elk-row { + display: table-row; +} + +/* Zebra striping earns its keep here: rows are one line tall and a wide node makes + it easy to read a key against the wrong value. */ +.elk-row:nth-child(even) { + background-color: var(--wypp-row-alt-bg); +} + +.elk-row-key, +.elk-row-value { + display: table-cell; + padding: 2px 6px; + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.elk-row-key { + text-align: right; + color: var(--wypp-muted); + max-width: 110px; +} + +/* A dict key that is itself a reference; the arrow leaving this cell says which. */ +.elk-key-ref { + font-style: italic; +} + +/* `return` rows get the current-frame accent: the legacy blue was invisible in dark + themes, and a return value is the one row worth finding at a glance. */ +.elk-row-key.return-value { + color: var(--wypp-accent-current-frame); + font-weight: 600; +} + +.elk-row-value { + max-width: 180px; + border-left: 1px solid var(--wypp-node-border); +} + +.elk-summary { + display: table-cell; + padding: 2px 6px; + font-style: italic; + color: var(--wypp-muted); +} + +/* Frames are washed blue so the two columns tell themselves apart even at zoom levels + where the header text is no longer legible. The wash goes in background-image, which + layers over the background-color .elk-node already set, so the node keeps an opaque + base and stays legible whatever the theme. */ +.elk-frame, +.elk-current-frame { + background-image: linear-gradient(var(--wypp-frame-bg), var(--wypp-frame-bg)); +} + +.elk-frame .elk-node-header, +.elk-current-frame .elk-node-header { + background-color: var(--wypp-frame-header-bg); +} + +.elk-frame { + border-left-color: var(--wypp-accent-frame); +} + +.elk-current-frame { + border-left-color: var(--wypp-accent-current-frame); +} + +/* One accent per heap type, so kind is readable at a glance and at small zoom + levels where the header text is no longer legible. */ +.elk-instance { + border-left-color: var(--wypp-accent-instance); +} + +.elk-list { + border-left-color: var(--wypp-accent-list); +} + +.elk-tuple { + border-left-color: var(--wypp-accent-tuple); +} + +.elk-dict { + border-left-color: var(--wypp-accent-dict); +} + +.elk-set { + border-left-color: var(--wypp-accent-set); +} + +.elk-edge { + fill: none; + stroke: var(--wypp-edge); + stroke-width: 1.5; +} + +.elk-arrowhead { + fill: var(--wypp-edge); +} + +.elk-edge-active { + stroke: var(--wypp-focus); + stroke-width: 2.5; +} + +/* Hovering a node fades everything it is not connected to. */ +.elk-canvas.elk-dimming .elk-node:not(.elk-node-active) { + opacity: 0.45; +} + +.elk-canvas.elk-dimming .elk-edge:not(.elk-edge-active) { + opacity: 0.2; +} + +.elk-node-active { + outline: 1px solid var(--wypp-focus); + outline-offset: -1px; +} diff --git a/src/programflow-visualization/web/webview.ts b/src/programflow-visualization/web/webview.ts new file mode 100644 index 00000000..09ea55ab --- /dev/null +++ b/src/programflow-visualization/web/webview.ts @@ -0,0 +1,268 @@ +// Render and control the program-flow visualization UI inside the webview +import type { Address, BackendTraceElem } from "../types"; +import { clearLayoutCache, prewarm, renderStep } from "./elk-view"; +import { attachPanZoom, type PanZoom } from "./pan-zoom"; + +type ResetMsg = { + command: "reset"; + trace: BackendTraceElem[]; + complete: boolean; +}; + +type AppendMsg = { + command: "append"; + elem: BackendTraceElem; + complete: boolean; +}; + +// Optional example trace format (designer mode) +type StaticTrace = { complete: boolean; trace: BackendTraceElem[] }; + +let trace: BackendTraceElem[] = []; +let traceComplete = false; +let traceIndex = 0; + +/** Heap objects the user has folded away. Kept across steps on purpose. */ +const collapsed = new Set
(); + +let panZoom: PanZoom | undefined; +/** Bounds of the last render, so the Fit button has something to fit to. */ +let lastBounds = { width: 0, height: 0 }; + +type NavType = "first" | "prev" | "next" | "last"; + +//DOM helpers +function $(sel: string): HTMLElement { + const el = document.querySelector(sel); + if (!el) {throw new Error(`Missing element: ${sel}`);} + return el as HTMLElement; +} + +function setDisabled(id: string, disabled: boolean) { + (document.querySelector(id) as HTMLButtonElement).disabled = disabled; +} + +function clamp(n: number, min: number, max: number) { + return Math.max(min, Math.min(max, n)); +} + +//Rendering +function updateControls() { + const max = Math.max(0, trace.length - 1); + traceIndex = clamp(traceIndex, 0, max); + + // Update slider + counters + const slider = $("#traceSlider") as HTMLInputElement; + slider.max = String(max); + slider.value = String(traceIndex); + + $("#traceMax").innerHTML = "/" + (traceComplete ? String(max) : "?"); + $("#indexCounter").innerHTML = String(traceIndex); + + // Update button enable/disable + setDisabled("#firstButton", traceIndex <= 0); + setDisabled("#prevButton", traceIndex <= 0); + setDisabled("#nextButton", traceIndex >= max); + setDisabled("#lastButton", traceIndex >= max); +} + +function renderCurrent() { + updateControls(); + + // Nothing to show yet + if (trace.length === 0) { + $("#stdout-log").textContent = ""; + $("#elk-canvas").textContent = ""; + return; + } + + const backendElem = trace[traceIndex]; + updateStdout(backendElem); + void renderElk(backendElem); +} + +function updateStdout(elem: BackendTraceElem) { + const stdoutLog = $("#stdout-log"); + stdoutLog.textContent = elem.stdout; + if (elem.traceback !== undefined) { + const traceback = document.createElement("span"); + traceback.className = "traceback-text"; + traceback.textContent = elem.traceback; + stdoutLog.append(traceback); + } + stdoutLog.scrollTo(0, stdoutLog.scrollHeight); +} + +function renderElk(elem: BackendTraceElem): Promise { + // A collapsed object can end up off-screen, so offer a way back without hunting for it. + $("#expandAllButton").hidden = collapsed.size === 0; + return renderStep($("#elk-canvas"), elem, collapsed, { + step: String(traceIndex), + onToggle: (address) => { + if (collapsed.has(address)) { + collapsed.delete(address); + } else { + collapsed.add(address); + } + void renderElk(trace[traceIndex]); + }, + onBounds: (width, height) => { + lastBounds = { width, height }; + panZoom?.autoFit(width, height); + }, + }).catch((err) => { + console.error("ELK layout failed:", err); + }); +} + +function postCurrentHighlight() { + if (trace.length === 0) { + return; + } + window.dispatchEvent(new CustomEvent("programflow:highlight", { + detail: { + filePath: trace[traceIndex].filePath, + line: trace[traceIndex].line, + }, + })); +} + +function navigate(type: NavType) { + const max = Math.max(0, trace.length - 1); + + switch (type) { + case "first": + traceIndex = 0; + break; + + case "prev": + traceIndex = Math.max(0, traceIndex - 1); + break; + + case "next": + traceIndex = Math.min(max, traceIndex + 1); + break; + + case "last": + traceIndex = max; + break; + } + + renderCurrent(); + postCurrentHighlight(); +} + +function slideTo(rawValue: string) { + traceIndex = Number(rawValue) || 0; + renderCurrent(); + postCurrentHighlight(); +} + +/** + * While the slider is being dragged only the cheap parts follow along. Layout is far too + * expensive to run per `input` event (plan 6.5), so it waits for `change`. + */ +function scrubTo(rawValue: string) { + traceIndex = Number(rawValue) || 0; + updateControls(); + if (trace.length > 0) { + updateStdout(trace[traceIndex]); + } + postCurrentHighlight(); +} + +//Incoming events (from vscode-host-adapter.ts) +window.addEventListener("programflow:reset", (e: Event) => { + const msg = (e as CustomEvent).detail; + trace = msg.trace ?? []; + traceComplete = !!msg.complete; + // A reset means a different trace, so every cached layout is keyed on stale indices. + clearLayoutCache(); + renderCurrent(); + postCurrentHighlight(); +}); + +window.addEventListener("programflow:append", (e: Event) => { + const msg = (e as CustomEvent).detail; + trace.push(msg.elem); + traceComplete = !!msg.complete; + renderCurrent(); +}); + + +function setupUi() { + panZoom = attachPanZoom($("#elk-viewport"), $("#elk-canvas")); + watchThemeChanges(); + // Warm elkjs up while the user is still reading the first step. + void prewarm().then(renderCurrent); + + // Disable until first reset arrives + setDisabled("#nextButton", true); + setDisabled("#lastButton", true); + setDisabled("#prevButton", true); + setDisabled("#firstButton", true); + + // Button clicks -> local navigation + $("#firstButton").addEventListener("click", () => { + navigate("first"); + }); + $("#prevButton").addEventListener("click", () => { + navigate("prev"); + }); + $("#nextButton").addEventListener("click", () => { + navigate("next"); + }); + $("#lastButton").addEventListener("click", () => { + navigate("last"); + }); + $("#expandAllButton").addEventListener("click", () => { + collapsed.clear(); + renderCurrent(); + }); + $("#fitButton").addEventListener("click", () => { + panZoom?.fit(lastBounds.width, lastBounds.height); + }); + $("#zoomInButton").addEventListener("click", () => { + panZoom?.zoomIn(); + }); + $("#zoomOutButton").addEventListener("click", () => { + panZoom?.zoomOut(); + }); + + // Slider input -> local navigation + const slider = $("#traceSlider") as HTMLInputElement; + slider.addEventListener("input", (e: Event) => { + scrubTo((e.target as HTMLInputElement).value); + }); + slider.addEventListener("change", (e: Event) => { + slideTo((e.target as HTMLInputElement).value); + }); + + // Optional: example trace mode + const anyWin = window as any; + const staticTrace: StaticTrace | undefined = anyWin.__PROGRAMFLOW_TRACE__; + + if (staticTrace?.trace) { + trace = staticTrace.trace; + traceComplete = !!staticTrace.complete; + traceIndex = 0; + renderCurrent(); + } +} + +document.addEventListener("DOMContentLoaded", setupUi); + +/** + * VS Code signals a theme change by swapping the class on ``. That changes the + * colours *and* potentially the font, so the cached layouts have to go (plan 6.5). + */ +function watchThemeChanges() { + const observer = new MutationObserver(() => { + clearLayoutCache(); + renderCurrent(); + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ["class", "style"], + }); +} diff --git a/src/test/unit/fixtures.ts b/src/test/unit/fixtures.ts new file mode 100644 index 00000000..aa4a5c1d --- /dev/null +++ b/src/test/unit/fixtures.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Heap addresses are numeric keys, so the fixtures below cannot use camelCase names. +// +// Shared by reachability.test.ts and graph-model.test.ts. Not a `.test.ts` file on +// purpose: the mocha glob is `out/test/unit/**/*.test.js`, so this is never run as a suite. +import type { Address, BackendTraceElem, HeapValue, NamedValue, StackElem, Value } from '../../programflow-visualization/types'; + +export function ref(address: Address): Value { + return { type: 'ref', value: address }; +} + +export function int(value: number): Value { + return { type: 'int', value }; +} + +export function str(value: string): Value { + return { type: 'str', value }; +} + +export function none(): Value { + return { type: 'none', value: 'None' }; +} + +export function local(name: string, value: Value): NamedValue { + return { ...value, name }; +} + +export function frame(frameName: string, locals: Array): StackElem { + return { frameName, locals }; +} + +export function list(...values: Array): HeapValue { + return { type: 'list', value: values }; +} + +export function tuple(...values: Array): HeapValue { + return { type: 'tuple', value: values }; +} + +export function set(...values: Array): HeapValue { + return { type: 'set', value: values }; +} + +export function instance(name: string, fields: Record): HeapValue { + return { type: 'instance', name, value: fields as unknown as Map }; +} + +export function dict(entries: Array<[Value, Value]>): HeapValue { + const keys: Record = {}; + const values: Record = {}; + entries.forEach(([key, value], index) => { + keys[index] = key; + values[index] = value; + }); + return { + type: 'dict', + keys: keys as unknown as Map, + value: values as unknown as Map, + }; +} + +/** + * `heap` is declared as a `Map` but arrives as a plain object after IPC/JSON - + * the fixtures deliberately reproduce the runtime shape, not the declared one. + */ +export function step(stack: Array, heap: Record): BackendTraceElem { + return { + line: 1, + filePath: 'example.py', + stack, + heap: heap as unknown as BackendTraceElem['heap'], + stdout: '', + traceback: undefined, + }; +} diff --git a/src/test/unit/graph-model.test.ts b/src/test/unit/graph-model.test.ts new file mode 100644 index 00000000..ef539390 --- /dev/null +++ b/src/test/unit/graph-model.test.ts @@ -0,0 +1,336 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Structure tests for buildGraph. No DOM and no ELK run - this is the pure half of the +// pipeline, the half that the browser harness is bad at checking (elk-task/elk-plan.md 7.8). +import * as assert from 'assert'; + +import { + buildGraph, + formatValue, + frameNodeId, + inputPortId, + keyPortId, + objectNodeId, + rowPortId, + rowsOfHeapValue, +} from '../../programflow-visualization/graph-model'; +import type { NodeModel, VizGraph } from '../../programflow-visualization/graph-model'; +import { outgoingRefs } from '../../programflow-visualization/reachability'; +import type { Address, BackendTraceElem, HeapValue } from '../../programflow-visualization/types'; +import { dict, frame, instance, int, list, local, none, ref, set, step, str, tuple } from './fixtures'; + +// Helpers --------------------------------------------------------------------- + +function build(elem: BackendTraceElem, collapsed: Array
= []): VizGraph { + return buildGraph(elem, new Set(collapsed)); +} + +function nodeIds(viz: VizGraph): Array { + return (viz.graph.children ?? []).map((child) => child.id); +} + +function node(viz: VizGraph, id: string) { + const found = (viz.graph.children ?? []).find((child) => child.id === id); + assert.ok(found, `no node ${id}`); + return found; +} + +function portIds(viz: VizGraph, id: string): Array { + return (node(viz, id).ports ?? []).map((port) => port.id); +} + +function model(viz: VizGraph, id: string): NodeModel { + const found = viz.nodes.get(id); + assert.ok(found, `no model ${id}`); + return found; +} + +/** Every edge as `sourcePort -> targetPort`, sorted, for order-free comparison. */ +function edges(viz: VizGraph): Array { + return (viz.graph.edges ?? []) + .map((edge) => `${edge.sources[0]} -> ${edge.targets[0]}`) + .sort(); +} + +/** How many references the step contains among the visible objects. */ +function refCount(elem: BackendTraceElem, heap: Record): number { + const fromStack = elem.stack.reduce( + (total, stackElem) => total + stackElem.locals.filter((value) => value.type === 'ref').length, + 0 + ); + const fromHeap = Object.values(heap).reduce( + (total, heapValue) => total + outgoingRefs(heapValue).length, + 0 + ); + return fromStack + fromHeap; +} + +// Tests ----------------------------------------------------------------------- + +suite('graph-model: nodes', () => { + test('frames come first, in stack order, then objects ascending by address', () => { + const elem = step( + [frame('', [local('xs', ref(7))]), frame('f', [local('ys', ref(2))])], + { 7: list(int(1)), 2: list(int(2)) } + ); + assert.deepStrictEqual(nodeIds(build(elem)), [ + frameNodeId(0), + frameNodeId(1), + objectNodeId(2), + objectNodeId(7), + ]); + }); + + test('address order is independent of heap insertion order, so steps stay stable', () => { + const ascending = step([frame('', [local('a', ref(1)), local('b', ref(9))])], { + 1: list(), 9: list(), + }); + const descending = step([frame('', [local('b', ref(9)), local('a', ref(1))])], { + 9: list(), 1: list(), + }); + assert.deepStrictEqual(nodeIds(build(ascending)).slice(2), nodeIds(build(descending)).slice(2)); + }); + + test('the last stack entry is the current frame, not the first', () => { + const elem = step([frame('', []), frame('g', []), frame('h', [])], {}); + assert.strictEqual(model(build(elem), frameNodeId(0)).kind, 'frame'); + assert.strictEqual(model(build(elem), frameNodeId(1)).kind, 'frame'); + assert.strictEqual(model(build(elem), frameNodeId(2)).kind, 'current-frame'); + }); + + test(' is shown as Global, other frames keep their name', () => { + const elem = step([frame('', []), frame('createGradeList', [])], {}); + assert.strictEqual(model(build(elem), frameNodeId(0)).header, 'Global'); + assert.strictEqual(model(build(elem), frameNodeId(1)).header, 'createGradeList'); + }); + + test('node kind and header follow the heap value; instances use their class name', () => { + const elem = step( + [frame('', [local('a', ref(1)), local('b', ref(2)), local('c', ref(3)), local('d', ref(4)), local('e', ref(5))])], + { + 1: list(int(1)), + 2: tuple(int(1)), + 3: set(int(1)), + 4: dict([[str('k'), int(1)]]), + 5: instance('Student', { name: str('lara') }), + } + ); + const viz = build(elem); + const kinds = [1, 2, 3, 4, 5].map((address) => model(viz, objectNodeId(address)).kind); + assert.deepStrictEqual(kinds, ['list', 'tuple', 'set', 'dict', 'instance']); + assert.strictEqual(model(viz, objectNodeId(5)).header, 'Student'); + assert.strictEqual(model(viz, objectNodeId(1)).header, 'list'); + }); + + test('only frames carry the layer constraint, and every node has fixed ports', () => { + const elem = step([frame('', [local('xs', ref(1))])], { 1: list() }); + const viz = build(elem); + assert.strictEqual( + node(viz, frameNodeId(0)).layoutOptions?.['elk.layered.layering.layerConstraint'], + 'FIRST_SEPARATE' + ); + assert.strictEqual( + node(viz, objectNodeId(1)).layoutOptions?.['elk.layered.layering.layerConstraint'], + undefined + ); + for (const child of viz.graph.children ?? []) { + assert.strictEqual(child.layoutOptions?.['elk.portConstraints'], 'FIXED_POS'); + } + }); +}); + +suite('graph-model: rows', () => { + test('list and tuple rows are indexed, set rows have no key', () => { + assert.deepStrictEqual( + rowsOfHeapValue(list(int(4), str('x'))).map((row) => row.key), + ['[0]', '[1]'] + ); + assert.deepStrictEqual(rowsOfHeapValue(set(str('a'), str('b'))).map((row) => row.key), ['', '']); + }); + + test('instance rows are named after their fields', () => { + const rows = rowsOfHeapValue(instance('Student', { name: str('lara'), age: int(21) })); + assert.deepStrictEqual(rows.map((row) => row.key), ['name', 'age']); + assert.deepStrictEqual(rows.map((row) => row.value), ['lara', '21']); + }); + + test('a reference cell has no text of its own, only a ref', () => { + const rows = rowsOfHeapValue(list(ref(3))); + assert.deepStrictEqual(rows, [{ key: '[0]', value: '', ref: 3 }]); + }); + + test('a dict key that is a reference is labelled [key] and recorded as keyRef', () => { + const rows = rowsOfHeapValue(dict([[ref(8), str('even pair')], [str('plain'), int(1)]])); + assert.deepStrictEqual(rows[0], { key: '[key]', value: 'even pair', ref: undefined, keyRef: 8 }); + assert.deepStrictEqual(rows[1], { key: 'plain', value: '1', ref: undefined, keyRef: undefined }); + }); + + test('a local named return is flagged for highlighting', () => { + const elem = step([frame('f', [local('x', int(1)), local('return', int(9))])], {}); + const rows = model(build(elem), frameNodeId(0)).rows; + assert.strictEqual(rows[0].isReturn, false); + assert.strictEqual(rows[1].isReturn, true); + }); + + test('formatValue renders None as None and leaves refs blank', () => { + assert.strictEqual(formatValue(none()), 'None'); + assert.strictEqual(formatValue(ref(5)), ''); + assert.strictEqual(formatValue(int(3)), '3'); + }); + + test('the summary counts rows and is singular for one', () => { + const elem = step( + [frame('', [local('a', ref(1)), local('b', ref(2)), local('c', ref(3))])], + { 1: list(int(1), int(2)), 2: dict([[str('k'), int(1)]]), 3: instance('S', { x: int(1) }) } + ); + const viz = build(elem); + assert.strictEqual(model(viz, objectNodeId(1)).summary, '2 elements'); + assert.strictEqual(model(viz, objectNodeId(2)).summary, '1 entry'); + assert.strictEqual(model(viz, objectNodeId(3)).summary, '1 field'); + }); +}); + +suite('graph-model: ports and edges', () => { + test('objects declare a west input port, frames do not', () => { + const elem = step([frame('', [local('xs', ref(1))])], { 1: list() }); + const viz = build(elem); + assert.deepStrictEqual(portIds(viz, objectNodeId(1)), [inputPortId(objectNodeId(1))]); + assert.deepStrictEqual(portIds(viz, frameNodeId(0)), [rowPortId(frameNodeId(0), 0)]); + }); + + test('only rows holding a reference get a row port', () => { + const elem = step([frame('', [local('xs', ref(1))])], { + 1: list(int(0), ref(2), int(0)), + 2: list(), + }); + const viz = build(elem); + assert.deepStrictEqual(portIds(viz, objectNodeId(1)), [ + inputPortId(objectNodeId(1)), + rowPortId(objectNodeId(1), 1), + ]); + }); + + test('a row with a reference key and a reference value gets both ports, key first', () => { + const elem = step([frame('', [local('d', ref(1))])], { + 1: dict([[ref(2), ref(3)]]), + 2: tuple(int(1)), + 3: list(), + }); + const viz = build(elem); + assert.deepStrictEqual(portIds(viz, objectNodeId(1)), [ + inputPortId(objectNodeId(1)), + keyPortId(objectNodeId(1), 0), + rowPortId(objectNodeId(1), 0), + ]); + }); + + test('edges leave the referencing row and land on the input port of the target', () => { + const elem = step([frame('', [local('xs', ref(1))])], { 1: list(ref(2)), 2: list() }); + assert.deepStrictEqual(edges(build(elem)), [ + `${objectNodeId(1)}:0 -> ${inputPortId(objectNodeId(2))}`, + `${frameNodeId(0)}:0 -> ${inputPortId(objectNodeId(1))}`, + ].sort()); + }); + + test('a dict key that is a reference gets its own edge', () => { + // Regression: the key object used to be visible with nothing pointing at it, so ELK + // laid it out as a root - left of the frames (elk-task/elk-plan.md 7.7). + const heap = { 1: dict([[ref(2), str('even pair')]]), 2: tuple(int(1), int(2)) }; + const elem = step([frame('', [local('byPair', ref(1))])], heap); + const viz = build(elem); + assert.deepStrictEqual(edges(viz), [ + `${frameNodeId(0)}:0 -> ${inputPortId(objectNodeId(1))}`, + `${keyPortId(objectNodeId(1), 0)} -> ${inputPortId(objectNodeId(2))}`, + ].sort()); + assert.strictEqual(refCount(elem, heap), edges(viz).length); + }); + + test('with nothing collapsed, edge count equals reference count', () => { + // Definition of done, criterion 2. + const heap: Record = { + 1: list(ref(2), ref(3), int(0)), + 2: instance('Student', { friend: ref(3), age: int(21) }), + 3: dict([[ref(4), ref(2)], [str('k'), int(1)]]), + 4: tuple(int(1), int(2)), + }; + const elem = step( + [frame('', [local('xs', ref(1)), local('n', int(3))]), frame('f', [local('s', ref(2))])], + heap + ); + const viz = build(elem); + assert.strictEqual(edges(viz).length, refCount(elem, heap)); + assert.strictEqual(new Set(edges(viz)).size, edges(viz).length, 'no duplicate edges'); + }); + + test('every edge endpoint is a port that exists on some node', () => { + const heap: Record = { + 1: list(ref(2)), + 2: dict([[ref(3), ref(1)]]), + 3: set(str('a')), + }; + const elem = step([frame('', [local('xs', ref(1))])], heap); + const viz = build(elem); + const declared = new Set( + (viz.graph.children ?? []).flatMap((child) => (child.ports ?? []).map((port) => port.id)) + ); + for (const edge of viz.graph.edges ?? []) { + assert.ok(declared.has(edge.sources[0]), `dangling source ${edge.sources[0]}`); + assert.ok(declared.has(edge.targets[0]), `dangling target ${edge.targets[0]}`); + } + }); + + test('a self-reference becomes a self-loop rather than being dropped', () => { + const elem = step([frame('', [local('xs', ref(1))])], { 1: list(ref(1)) }); + assert.deepStrictEqual(edges(build(elem)), [ + `${frameNodeId(0)}:0 -> ${inputPortId(objectNodeId(1))}`, + `${objectNodeId(1)}:0 -> ${inputPortId(objectNodeId(1))}`, + ].sort()); + }); + + test('a cycle terminates and both directions are drawn', () => { + const elem = step([frame('', [local('left', ref(1))])], { + 1: list(ref(2)), + 2: list(ref(1)), + }); + assert.strictEqual(edges(build(elem)).length, 3); + }); +}); + +suite('graph-model: collapsing', () => { + test('a collapsed object keeps its input port but loses its row ports and edges', () => { + const elem = step([frame('', [local('xs', ref(1))])], { 1: list(ref(2)), 2: list() }); + const viz = build(elem, [1]); + assert.deepStrictEqual(portIds(viz, objectNodeId(1)), [inputPortId(objectNodeId(1))]); + assert.deepStrictEqual(edges(viz), [`${frameNodeId(0)}:0 -> ${inputPortId(objectNodeId(1))}`]); + }); + + test('a collapsed node keeps its rows in the model, for the summary and for re-expanding', () => { + const elem = step([frame('', [local('xs', ref(1))])], { 1: list(int(1), int(2)) }); + const collapsedModel = model(build(elem, [1]), objectNodeId(1)); + assert.strictEqual(collapsedModel.collapsed, true); + assert.strictEqual(collapsedModel.rows.length, 2); + assert.strictEqual(collapsedModel.summary, '2 elements'); + }); + + test('collapsing removes exclusively downstream nodes but keeps shared ones', () => { + const elem = step( + [frame('', [local('xs', ref(1)), local('shared', ref(3))])], + { 1: list(ref(2), ref(3)), 2: list(), 3: list() } + ); + const ids = nodeIds(build(elem, [1])); + assert.ok(!ids.includes(objectNodeId(2)), 'anonymous child should be gone'); + assert.ok(ids.includes(objectNodeId(3)), 'child with another root should stay'); + }); + + test('collapsing an address that is not on screen changes nothing', () => { + const elem = step([frame('', [local('xs', ref(1))])], { 1: list(int(1)) }); + assert.deepStrictEqual(nodeIds(build(elem, [99])), nodeIds(build(elem))); + assert.deepStrictEqual(edges(build(elem, [99])), edges(build(elem))); + }); + + test('frames are never collapsible', () => { + const elem = step([frame('', [local('n', int(1))])], {}); + const frameModel = model(build(elem), frameNodeId(0)); + assert.strictEqual(frameModel.address, undefined); + assert.strictEqual(frameModel.collapsed, false); + }); +}); diff --git a/src/test/unit/reachability.test.ts b/src/test/unit/reachability.test.ts new file mode 100644 index 00000000..23bff004 --- /dev/null +++ b/src/test/unit/reachability.test.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Heap addresses are numeric keys, so the fixtures below cannot use camelCase names. +import * as assert from 'assert'; + +import { outgoingRefs, rootRefs, visibleAddresses } from '../../programflow-visualization/reachability'; +import type { Address, BackendTraceElem } from '../../programflow-visualization/types'; +import { dict, frame, instance, int, list, local, ref, step, str } from './fixtures'; + +function visible(elem: BackendTraceElem, collapsed: Array
= []): Array
{ + return [...visibleAddresses(elem, new Set(collapsed))].sort((a, b) => a - b); +} + +// Tests ----------------------------------------------------------------------- + +suite('reachability: traversal basics', () => { + test('empty heap and empty stack yield nothing', () => { + assert.deepStrictEqual(visible(step([frame('', [])], {})), []); + }); + + test('non-reference locals are not roots', () => { + const elem = step([frame('', [local('count', int(3))])], { 0: list(int(1)) }); + assert.deepStrictEqual(visible(elem), []); + }); + + test('roots come from every frame, not only the current one', () => { + const elem = step( + [ + frame('', [local('outer', ref(0))]), + frame('helper', [local('inner', ref(1))]), + ], + { 0: list(int(1)), 1: list(int(2)) } + ); + assert.deepStrictEqual(visible(elem), [0, 1]); + }); + + test('objects unreachable from the stack are dropped', () => { + const elem = step([frame('', [local('kept', ref(0))])], { + 0: list(int(1)), + 1: list(int(2)), // garbage: nothing points here + }); + assert.deepStrictEqual(visible(elem), [0]); + }); + + test('references to addresses missing from the heap are ignored', () => { + const elem = step([frame('', [local('dangling', ref(7))])], {}); + assert.deepStrictEqual(visible(elem), []); + }); +}); + +suite('reachability: container types', () => { + test('instance fields are followed', () => { + const elem = step([frame('', [local('subject', ref(0))])], { + 0: instance('Subject', { name: str('AuD'), students: ref(1) }), + 1: list(ref(2)), + 2: instance('Student', { name: str('Tim'), grade: int(1) }), + }); + assert.deepStrictEqual(visible(elem), [0, 1, 2]); + }); + + test('dict reference keys and reference values are both followed', () => { + const elem = step([frame('', [local('byPair', ref(0))])], { + 0: dict([[ref(1), ref(2)]]), + 1: { type: 'tuple', value: [int(1), int(2)] }, + 2: list(str('a')), + }); + assert.deepStrictEqual(visible(elem), [0, 1, 2]); + }); + + test('set and tuple elements are followed', () => { + const elem = step([frame('', [local('mixed', ref(0))])], { + 0: { type: 'tuple', value: [ref(1), int(42), str('text')] }, + 1: { type: 'set', value: [ref(2)] }, + 2: list(int(1)), + }); + assert.deepStrictEqual(visible(elem), [0, 1, 2]); + }); + + test('outgoingRefs reports only reference values', () => { + assert.deepStrictEqual(outgoingRefs(list(int(1), ref(4), str('x'), ref(9))), [4, 9]); + assert.deepStrictEqual(outgoingRefs(instance('S', { a: int(1), b: ref(3) })), [3]); + assert.deepStrictEqual(outgoingRefs(dict([[str('k'), ref(5)]])), [5]); + }); + + test('rootRefs reports only reference locals', () => { + const elem = step( + [frame('', [local('n', int(1)), local('a', ref(2)), local('b', ref(3))])], + {} + ); + assert.deepStrictEqual(rootRefs(elem), [2, 3]); + }); +}); + +suite('reachability: cycles terminate', () => { + test('self-referencing list', () => { + const elem = step([frame('', [local('selfRef', ref(0))])], { + 0: list(ref(0)), + }); + assert.deepStrictEqual(visible(elem), [0]); + }); + + test('two-object cycle', () => { + const elem = step([frame('', [local('left', ref(0))])], { + 0: list(ref(1)), + 1: list(ref(0)), + }); + assert.deepStrictEqual(visible(elem), [0, 1]); + }); + + test('cycle reachable only through a collapsed object disappears', () => { + const elem = step([frame('', [local('outer', ref(0))])], { + 0: list(ref(1)), + 1: list(ref(2)), + 2: list(ref(1)), + }); + assert.deepStrictEqual(visible(elem), [0, 1, 2]); + assert.deepStrictEqual(visible(elem, [0]), [0]); + }); +}); + +suite('reachability: collapsing', () => { + // Mirrors elk-task/example-anonymous.py: `data = [[1, 2], [3, 4]]` + const anonymous = step([frame('', [local('data', ref(0))])], { + 0: list(ref(1), ref(2)), + 1: list(int(1), int(2)), + 2: list(int(3), int(4)), + }); + + test('nothing is hidden while nothing is collapsed', () => { + assert.deepStrictEqual(visible(anonymous), [0, 1, 2]); + }); + + test('the collapsed object itself stays visible', () => { + assert.ok(visible(anonymous, [0]).includes(0)); + }); + + test('exclusively downstream objects disappear', () => { + assert.deepStrictEqual(visible(anonymous, [0]), [0]); + }); + + test('expanding again restores the full picture', () => { + assert.deepStrictEqual(visible(anonymous, []), visible(anonymous)); + }); + + // Mirrors elk-task/example.py: two subject lists sharing some students. + const shared = step( + [frame('', [local('aud', ref(0)), local('prog1', ref(1))])], + { + 0: list(ref(2), ref(3)), // aud -> lara, tim + 1: list(ref(2)), // prog1 -> lara + 2: instance('Student', { name: str('Lara') }), + 3: instance('Student', { name: str('Tim') }), + } + ); + + test('collapsing one referrer keeps objects the other still reaches', () => { + // lara (2) survives because prog1 still points at her, tim (3) does not. + assert.deepStrictEqual(visible(shared, [0]), [0, 1, 2]); + }); + + test('collapsing every referrer removes the shared object', () => { + assert.deepStrictEqual(visible(shared, [0, 1]), [0, 1]); + }); + + test('an object with its own name survives collapsing its container', () => { + // elk-task/example-anonymous.py: `shared` is a global, `holder = [shared]`. + const elem = step( + [frame('', [local('shared', ref(1)), local('holder', ref(0))])], + { + 0: list(ref(1)), + 1: instance('Student', { name: str('Cleo') }), + } + ); + assert.deepStrictEqual(visible(elem, [0]), [0, 1]); + }); + + test('collapsing cuts only at the collapsed node, not above or below it', () => { + // 0 -> 1 -> 2 -> 3, collapse the middle one. + const chain = step([frame('', [local('head', ref(0))])], { + 0: list(ref(1)), + 1: list(ref(2)), + 2: list(ref(3)), + 3: list(int(0)), + }); + assert.deepStrictEqual(visible(chain, [1]), [0, 1]); + assert.deepStrictEqual(visible(chain, [2]), [0, 1, 2]); + }); + + test('collapsing an object that is not in the heap changes nothing', () => { + assert.deepStrictEqual(visible(anonymous, [99]), [0, 1, 2]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index b65c7451..e5432907 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,11 @@ "lib": [ "es6" ], + "types": ["node"], "sourceMap": true, "rootDir": "src", + /* elkjs ships a .d.ts that does not survive strict checking. */ + "skipLibCheck": true, "strict": true /* enable all strict type-checking options */ /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ @@ -16,6 +19,7 @@ }, "exclude": [ "node_modules", - ".vscode-test" + ".vscode-test", + "src/programflow-visualization/web/**" ] } diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index 136b385b..00000000 --- a/webpack.config.js +++ /dev/null @@ -1,12 +0,0 @@ -const path = require('path'); - -module.exports = { - entry: { - linkerline: './media/programflow-visualization/linkerline.js', - }, - output: { - path: path.resolve(__dirname, 'out'), - filename: '[name].bundle.js', - }, - mode: "production" -};