diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index a57f5c729..000000000 --- a/.eslintignore +++ /dev/null @@ -1,13 +0,0 @@ -node_modules/ -dist/ -coverage/ -*.d.ts -*.config.js -*.config.ts - -# Auto-generated files -packages/format/src/schemas/yamls.ts - -# Docusaurus -packages/web/.docusaurus/ -packages/web/build/ diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 76fdd6032..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint"], - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], - "rules": { - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": [ - "error", - { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" } - ], - "@typescript-eslint/no-namespace": "off", - "@typescript-eslint/no-empty-object-type": "off", - "no-console": "warn", - "require-yield": "off", - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": ["../types/*/index*", "../types/*/index"], - "message": "Use #types/* alias instead of relative imports" - }, - { - "group": [ - "../materials/*", - "../data/*", - "../pointer/*", - "../program/*", - "../type/*" - ], - "message": "Use #types/* alias instead of relative imports" - }, - { - "group": ["../../test/*", "../../../test/*"], - "message": "Use #test/* alias instead of relative imports" - }, - { - "group": ["../describe*"], - "message": "Use #describe alias instead of relative imports" - } - ] - } - ] - }, - "env": { - "node": true, - "es2022": true - } -} diff --git a/bin/start b/bin/start index 17c20fb61..ffd64a77b 100755 --- a/bin/start +++ b/bin/start @@ -10,10 +10,12 @@ else fi # Run the commands with concurrently -concurrently --names=format,pointers,bugc,playground,web,tests \ +concurrently --names=format,pointers,evm,bugc,programs-react,playground,web,tests \ "cd ./packages/format && yarn watch" \ "cd ./packages/pointers && yarn watch" \ + "cd ./packages/evm && yarn watch" \ "cd ./packages/bugc && yarn watch" \ + "cd ./packages/programs-react && yarn watch" \ "cd ./packages/playground && yarn watch" \ "cd ./packages/web && yarn start $DOCUSAURUS_NO_OPEN" \ "sleep 5 && yarn test --ui --watch --coverage $VITEST_NO_OPEN" diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..a80e6d847 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,79 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts", "**/*.tsx"], + plugins: { + "react-hooks": reactHooks, + }, + languageOptions: { + globals: { + ...globals.node, + ...globals.es2022, + }, + parserOptions: { + projectService: true, + }, + }, + rules: { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": [ + "error", + { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-empty-object-type": "off", + "no-console": "warn", + "require-yield": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["../types/*/index*", "../types/*/index"], + message: "Use #types/* alias instead of relative imports", + }, + { + group: [ + "../materials/*", + "../data/*", + "../pointer/*", + "../program/*", + "../type/*", + ], + message: "Use #types/* alias instead of relative imports", + }, + { + group: ["../../test/*", "../../../test/*"], + message: "Use #test/* alias instead of relative imports", + }, + { + group: ["../describe*"], + message: "Use #describe alias instead of relative imports", + }, + ], + }, + ], + }, + }, + { + ignores: [ + "node_modules/", + "**/dist/", + "coverage/", + "**/*.d.ts", + "**/*.config.js", + "**/*.config.ts", + "packages/format/src/schemas/yamls.ts", + "packages/web/.docusaurus/", + "packages/web/build/", + ], + }, +); diff --git a/package.json b/package.json index c58ffd699..1d299a18f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packages/*" ], "scripts": { - "build": "tsc --build packages/format packages/pointers packages/bugc", + "build": "tsc --build packages/format packages/pointers packages/evm packages/bugc packages/programs-react packages/pointers-react", "bundle": "tsx ./bin/bundle-schema.ts", "test": "vitest", "test:coverage": "vitest run --coverage", @@ -15,21 +15,25 @@ "prepare": "husky", "format": "prettier --write .", "format:check": "prettier --check .", - "lint": "eslint . --ext .ts,.tsx", - "lint:fix": "eslint . --ext .ts,.tsx --fix" + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "devDependencies": { + "@eslint/js": "^9.39.2", "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "concurrently": "^8.2.2", - "eslint": "^8.57.1", + "eslint": "^9.0.0", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^17.0.0", "husky": "^9.1.7", "lerna": "^8.2.4", "lint-staged": "^15.4.1", "prettier": "^3.4.2", "tsx": "^4.21.0", + "typescript-eslint": "^8.53.0", "vitest": "^3.2.4" }, "lint-staged": { diff --git a/packages/bugc-react/package.json b/packages/bugc-react/package.json new file mode 100644 index 000000000..e3e448fa8 --- /dev/null +++ b/packages/bugc-react/package.json @@ -0,0 +1,59 @@ +{ + "name": "@ethdebug/bugc-react", + "version": "0.1.0-0", + "description": "React components for visualizing BUG compiler output", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "license": "MIT", + "imports": { + "#components/*": { + "types": "./src/components/*.tsx", + "default": "./dist/src/components/*.js" + }, + "#hooks/*": { + "types": "./src/hooks/*.ts", + "default": "./dist/src/hooks/*.js" + }, + "#utils/*": { + "types": "./src/utils/*.ts", + "default": "./dist/src/utils/*.js" + }, + "#types": { + "types": "./src/types.ts", + "default": "./dist/src/types.js" + } + }, + "scripts": { + "prepare": "tsc", + "build": "tsc", + "watch": "tsc --watch --preserveWatchOutput", + "test": "vitest run" + }, + "dependencies": { + "@ethdebug/bugc": "^0.1.0-0" + }, + "devDependencies": { + "@testing-library/dom": "^10.0.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "jsdom": "^26.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "optionalDependencies": { + "@monaco-editor/react": "^4.7.0", + "dagre": "^0.8.5", + "react-flow-renderer": "^10.3.17" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/bugc-react/src/components/AstView.css b/packages/bugc-react/src/components/AstView.css new file mode 100644 index 000000000..453b38ba2 --- /dev/null +++ b/packages/bugc-react/src/components/AstView.css @@ -0,0 +1,21 @@ +/** + * Styles for AstView component. + */ + +@import "./variables.css"; + +.ast-view { + height: 100%; + overflow: auto; +} + +.ast-json { + margin: 0; + padding: 1rem; + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 0.875rem; + line-height: 1.5; + color: var(--bugc-text-code); + white-space: pre; + overflow: auto; +} diff --git a/packages/bugc-react/src/components/AstView.tsx b/packages/bugc-react/src/components/AstView.tsx new file mode 100644 index 000000000..b7744567e --- /dev/null +++ b/packages/bugc-react/src/components/AstView.tsx @@ -0,0 +1,45 @@ +/** + * AstView component for displaying the AST of a BUG program. + */ + +import React from "react"; +import type { Ast } from "@ethdebug/bugc"; + +/** + * Props for AstView component. + */ +export interface AstViewProps { + /** The AST to display */ + ast: Ast.Program; +} + +/** + * Displays a BUG program's Abstract Syntax Tree as formatted JSON. + * + * Automatically excludes parent references to avoid circular structures. + * + * @param props - AST to display + * @returns AstView element + * + * @example + * ```tsx + * + * ``` + */ +export function AstView({ ast }: AstViewProps): JSX.Element { + // Format AST as JSON, excluding parent references to avoid circular structure + const astJson = JSON.stringify( + ast, + (key, value) => { + if (key === "parent") return undefined; + return value; + }, + 2, + ); + + return ( +
+
{astJson}
+
+ ); +} diff --git a/packages/bugc-react/src/components/BytecodeView.css b/packages/bugc-react/src/components/BytecodeView.css new file mode 100644 index 000000000..b66318e6e --- /dev/null +++ b/packages/bugc-react/src/components/BytecodeView.css @@ -0,0 +1,115 @@ +/** + * Styles for BytecodeView component. + */ + +@import "./variables.css"; + +.bytecode-view { + height: 100%; + overflow: auto; +} + +.bytecode-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background-color: var(--bugc-bg-secondary); + border-bottom: 1px solid var(--bugc-border-primary); + position: sticky; + top: 0; + z-index: 1; +} + +.bytecode-header h3 { + margin: 0; + font-size: 1rem; + color: var(--bugc-text-primary); +} + +.bytecode-stats { + display: flex; + gap: 1rem; + font-size: 0.813rem; + color: var(--bugc-text-secondary); +} + +.bytecode-content { + padding: 1rem; +} + +.bytecode-section { + margin-bottom: 2rem; +} + +.bytecode-section h4 { + margin: 0 0 0.5rem 0; + font-size: 0.875rem; + color: var(--bugc-text-primary); +} + +.bytecode-hex, +.bytecode-disassembly { + margin: 0; + padding: 1rem; + background-color: var(--bugc-bg-code); + border: 1px solid var(--bugc-border-primary); + border-radius: 4px; + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 0.813rem; + line-height: 1.5; + color: var(--bugc-text-code); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.bytecode-disassembly { + white-space: pre; + word-break: normal; +} + +.bytecode-separator { + margin: 2rem 1rem; + border: none; + border-top: 1px solid var(--bugc-border-primary); +} + +.bytecode-disassembly-interactive { + padding: 1rem; + background-color: var(--bugc-bg-code); + border: 1px solid var(--bugc-border-primary); + border-radius: 4px; + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 0.813rem; + line-height: 1.5; + overflow-x: auto; +} + +.opcode-line { + display: flex; + gap: 1rem; + padding: 0.125rem 0.5rem; + border-radius: 3px; + transition: background-color 0.15s ease; +} + +.opcode-line.has-debug-info:hover { + background-color: var(--bugc-bg-hover); +} + +.opcode-line .pc { + color: var(--bugc-syntax-address); + min-width: 3rem; + text-align: right; +} + +.opcode-line .opcode { + color: var(--bugc-syntax-opcode); + min-width: 6rem; + font-weight: 500; +} + +.opcode-line .immediates { + color: var(--bugc-syntax-number); +} diff --git a/packages/bugc-react/src/components/BytecodeView.tsx b/packages/bugc-react/src/components/BytecodeView.tsx new file mode 100644 index 000000000..441b0f5e0 --- /dev/null +++ b/packages/bugc-react/src/components/BytecodeView.tsx @@ -0,0 +1,204 @@ +/** + * BytecodeView component for displaying compiled EVM bytecode. + */ + +import React from "react"; +import type { Evm } from "@ethdebug/bugc"; +import type { BytecodeOutput, SourceRange } from "#types"; +import { extractSourceRange } from "#utils/debugUtils"; +import { useEthdebugTooltip } from "#hooks/useEthdebugTooltip"; +import { EthdebugTooltip } from "./EthdebugTooltip.js"; + +/** + * Props for BytecodeView component. + */ +export interface BytecodeViewProps { + /** Compiled bytecode output */ + bytecode: BytecodeOutput; + /** Callback when hovering over an opcode with source ranges */ + onOpcodeHover?: (ranges: SourceRange[]) => void; +} + +interface InstructionsViewProps { + instructions: Evm.Instruction[]; + onOpcodeHover?: (ranges: SourceRange[]) => void; +} + +function InstructionsView({ + instructions, + onOpcodeHover, +}: InstructionsViewProps): JSX.Element { + const { + tooltip, + setTooltip, + showTooltip, + pinTooltip, + hideTooltip, + closeTooltip, + } = useEthdebugTooltip(); + + let pc = 0; + + const handleOpcodeMouseEnter = (sourceRanges: SourceRange[]) => { + onOpcodeHover?.(sourceRanges); + }; + + const handleOpcodeMouseLeave = () => { + onOpcodeHover?.([]); + }; + + const handleDebugIconMouseEnter = ( + e: React.MouseEvent, + instruction: Evm.Instruction, + ) => { + if (instruction.debug?.context) { + showTooltip(e, JSON.stringify(instruction.debug.context, null, 2)); + } + }; + + const handleDebugIconClick = ( + e: React.MouseEvent, + instruction: Evm.Instruction, + ) => { + if (instruction.debug?.context) { + pinTooltip(e, JSON.stringify(instruction.debug.context, null, 2)); + } + }; + + return ( +
+ {instructions.map((instruction, idx) => { + const currentPc = pc; + pc += 1 + (instruction.immediates?.length || 0); + + const sourceRanges = extractSourceRange(instruction.debug?.context); + const hasDebugInfo = !!instruction.debug?.context; + + return ( +
handleOpcodeMouseEnter(sourceRanges)} + onMouseLeave={handleOpcodeMouseLeave} + > + {hasDebugInfo ? ( + handleDebugIconMouseEnter(e, instruction)} + onMouseLeave={hideTooltip} + onClick={(e) => handleDebugIconClick(e, instruction)} + > + ℹ + + ) : ( + + )} + {currentPc.toString().padStart(4, "0")} + {instruction.mnemonic} + {instruction.immediates && instruction.immediates.length > 0 && ( + + 0x + {instruction.immediates + .map((b) => b.toString(16).padStart(2, "0")) + .join("")} + + )} +
+ ); + })} + +
+ ); +} + +/** + * Displays compiled EVM bytecode with interactive disassembly. + * + * Shows both hex representation and disassembled instructions. + * Supports hovering over opcodes to highlight source ranges. + * + * @param props - Bytecode output and callbacks + * @returns BytecodeView element + * + * @example + * ```tsx + * setHighlightedRanges(ranges)} + * /> + * ``` + */ +export function BytecodeView({ + bytecode, + onOpcodeHover, +}: BytecodeViewProps): JSX.Element { + const runtimeHex = Array.from(bytecode.runtime) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + const constructorHex = bytecode.create + ? Array.from(bytecode.create) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + : null; + + return ( +
+ {bytecode.create && ( + <> +
+

Constructor Bytecode

+
+ Size: {bytecode.create.length / 2} bytes +
+
+ +
+
+

Hex

+
{constructorHex}
+
+ +
+

Instructions

+ {bytecode.createInstructions && ( + + )} +
+
+ +
+ + )} + +
+

{bytecode.create ? "Runtime Bytecode" : "EVM Bytecode"}

+
+ Size: {bytecode.runtime.length / 2} bytes +
+
+ +
+
+

Hex

+
{runtimeHex}
+
+ +
+

Instructions

+ +
+
+
+ ); +} diff --git a/packages/bugc-react/src/components/CfgView.css b/packages/bugc-react/src/components/CfgView.css new file mode 100644 index 000000000..16e51dba0 --- /dev/null +++ b/packages/bugc-react/src/components/CfgView.css @@ -0,0 +1,206 @@ +/** + * Styles for CfgView component. + */ + +@import "./variables.css"; + +.cfg-view { + display: flex; + flex-direction: column; + height: 100%; +} + +.cfg-header { + padding: 1rem; + border-bottom: 1px solid var(--bugc-border-primary); + background: var(--bugc-bg-secondary); +} + +.cfg-header h3 { + margin: 0; + font-size: 1.1rem; + color: var(--bugc-text-primary); +} + +.cfg-content { + flex: 1; + display: flex; + min-height: 0; +} + +.cfg-graph { + flex: 1; + position: relative; +} + +.cfg-sidebar { + width: 400px; + border-left: 1px solid var(--bugc-border-primary); + padding: 1rem; + overflow-y: auto; + background: var(--bugc-bg-secondary); + color: var(--bugc-text-primary); +} + +.cfg-sidebar h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + color: var(--bugc-text-primary); + display: flex; + justify-content: space-between; + align-items: center; +} + +.cfg-sidebar h5 { + margin: 0.5rem 0; + font-size: 0.9rem; + color: var(--bugc-text-secondary); +} + +.cfg-sidebar-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--bugc-text-secondary); + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; +} + +.cfg-sidebar-close:hover { + background-color: var(--bugc-bg-hover); + color: var(--bugc-text-primary); +} + +/* Custom node styles */ +.cfg-node { + background: var(--bugc-cfg-node-bg); + border: 2px solid var(--bugc-cfg-node-border); + border-radius: 8px; + padding: 10px 15px; + min-width: 120px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.cfg-node.entry { + border-color: var(--bugc-cfg-entry-border); + background: var(--bugc-cfg-entry-bg); +} + +.cfg-node.selected { + border-width: 3px; + box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3); +} + +.cfg-node:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.cfg-node-header { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 4px; +} + +.cfg-node-header strong { + font-family: "Courier New", Courier, monospace; + font-size: 14px; +} + +.cfg-view .entry-badge { + background: var(--bugc-accent-green); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; +} + +.cfg-node-stats { + font-size: 12px; + color: var(--bugc-text-secondary); +} + +/* Instruction display */ +.block-instructions { + margin-top: 1rem; +} + +.instruction-list { + background: var(--bugc-bg-primary); + border: 1px solid var(--bugc-border-primary); + border-radius: 4px; + padding: 1rem; + margin: 0; + font-family: "Courier New", Courier, monospace; + font-size: 0.85rem; + line-height: 1.4; + overflow-x: auto; + color: var(--bugc-text-primary); +} + +.cfg-view .instruction { + margin: 0.25rem 0; + padding: 0.125rem 0; +} + +.cfg-view .instruction.terminator { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px dashed var(--bugc-border-primary); + color: var(--bugc-accent-red); + font-weight: bold; +} + +/* React Flow overrides */ +.cfg-view .react-flow__attribution { + display: none; +} + +.cfg-view .react-flow__edge-path { + stroke-width: 2; +} + +.cfg-view .react-flow__edge-text { + font-size: 12px; + font-weight: 600; +} + +.cfg-view .react-flow__handle { + width: 8px; + height: 8px; + background: var(--bugc-cfg-node-border); + border: 2px solid var(--bugc-bg-primary); +} + +.cfg-view .react-flow__handle-top { + top: -4px; +} + +.cfg-view .react-flow__handle-bottom { + bottom: -4px; +} + +.cfg-view .react-flow__handle-left { + left: -4px; +} + +.cfg-view .react-flow__handle-right { + right: -4px; +} + +/* React Flow background in light/dark mode */ +.cfg-view .react-flow__background { + background-color: var(--bugc-bg-primary); +} diff --git a/packages/bugc-react/src/components/CfgView.tsx b/packages/bugc-react/src/components/CfgView.tsx new file mode 100644 index 000000000..dba9b592a --- /dev/null +++ b/packages/bugc-react/src/components/CfgView.tsx @@ -0,0 +1,562 @@ +/** + * CfgView component for displaying control flow graphs. + * + * Note: This component requires optional peer dependencies: + * - react-flow-renderer + * - dagre + */ + +import React, { useMemo, useCallback, useState, useEffect } from "react"; +import type { Ir } from "@ethdebug/bugc"; + +/** + * Props for CfgView component. + */ +export interface CfgViewProps { + /** The IR module to visualize */ + ir: Ir.Module; + /** Show comparison view (not implemented) */ + showComparison?: boolean; + /** IR for comparison (not implemented) */ + comparisonIr?: Ir.Module; +} + +interface BlockNodeData { + label: string; + block: Ir.Block; + isEntry: boolean; + instructionCount: number; + functionName?: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let rfModule: any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let dagreModule: any; +let dependenciesLoaded = false; + +async function loadDependencies(): Promise { + if (dependenciesLoaded) { + return true; + } + + try { + rfModule = await import("react-flow-renderer"); + dagreModule = await import("dagre"); + dependenciesLoaded = true; + return true; + } catch { + return false; + } +} + +function BlockNodeComponent(props: { + data: BlockNodeData; + selected: boolean; +}): JSX.Element { + const { data, selected } = props; + + if (!rfModule) { + return
Loading...
; + } + + const { Handle, Position } = rfModule; + + return ( +
+ + +
+ + {data.functionName}::{data.label} + + {data.isEntry && entry} +
+
+ {data.instructionCount} instruction + {data.instructionCount !== 1 ? "s" : ""} +
+ + +
+ ); +} + +function CfgViewContent({ ir }: CfgViewProps): JSX.Element { + const [selectedNode, setSelectedNode] = useState(null); + + if (!rfModule || !dagreModule) { + return ( +
+
+

Control Flow Graph

+
+
+

Loading dependencies...

+
+
+ ); + } + + const { + default: ReactFlow, + Controls, + Background, + useNodesState, + useEdgesState, + useReactFlow, + MarkerType, + } = rfModule; + + const dagre = dagreModule; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const reactFlow = useReactFlow(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const { initialNodes, initialEdges } = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nodes: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const edges: any[] = []; + + const processFunction = (func: Ir.Function, funcName: string) => { + const blockEntries = Array.from(func.blocks.entries()); + + blockEntries.forEach(([blockId, block]) => { + const nodeId = `${funcName}:${blockId}`; + + nodes.push({ + id: nodeId, + type: "block", + position: { x: 0, y: 0 }, + data: { + label: blockId, + block, + isEntry: blockId === func.entry, + instructionCount: block.instructions.length + 1, + functionName: funcName, + }, + }); + }); + + blockEntries.forEach(([blockId, block]) => { + const sourceId = `${funcName}:${blockId}`; + const term = block.terminator; + + if (term.kind === "jump") { + const targetId = `${funcName}:${term.target}`; + edges.push({ + id: `${sourceId}-${targetId}`, + source: sourceId, + target: targetId, + sourceHandle: "bottom", + targetHandle: "top", + markerEnd: { + type: MarkerType.ArrowClosed, + }, + }); + } else if (term.kind === "branch") { + const trueTargetId = `${funcName}:${term.trueTarget}`; + const falseTargetId = `${funcName}:${term.falseTarget}`; + + edges.push({ + id: `${sourceId}-${trueTargetId}-true`, + source: sourceId, + target: trueTargetId, + sourceHandle: "bottom", + targetHandle: "top", + label: "true", + labelBgStyle: { fill: "#e8f5e9" }, + style: { stroke: "#4caf50" }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#4caf50", + }, + }); + edges.push({ + id: `${sourceId}-${falseTargetId}-false`, + source: sourceId, + target: falseTargetId, + sourceHandle: "bottom", + targetHandle: "top", + label: "false", + labelBgStyle: { fill: "#ffebee" }, + style: { stroke: "#f44336" }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#f44336", + }, + }); + } else if (term.kind === "call") { + const continuationId = `${funcName}:${term.continuation}`; + edges.push({ + id: `${sourceId}-${continuationId}-call-cont`, + source: sourceId, + target: continuationId, + sourceHandle: "bottom", + targetHandle: "top", + label: `after ${term.function}()`, + labelBgStyle: { fill: "#f3e8ff" }, + style: { stroke: "#9333ea" }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#9333ea", + }, + }); + } + }); + }; + + if (ir.functions) { + for (const [funcName, func] of ir.functions.entries()) { + processFunction(func, funcName); + } + } + + if (ir.create) { + processFunction(ir.create, "create"); + } + + processFunction(ir.main, "main"); + + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + dagreGraph.setGraph({ + rankdir: "TB", + nodesep: 80, + ranksep: 120, + edgesep: 50, + }); + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { width: 200, height: 80 }); + }); + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + + dagre.layout(dagreGraph); + + const layoutedNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - 100, + y: nodeWithPosition.y - 40, + }, + }; + }); + + return { initialNodes: layoutedNodes, initialEdges: edges }; + }, [ir, MarkerType, dagre]); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes); + // eslint-disable-next-line react-hooks/rules-of-hooks + const [edgesState, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + setNodes(initialNodes); + setEdges(initialEdges); + setTimeout(() => { + reactFlow?.fitView?.({ padding: 0.2, minZoom: 0.1, maxZoom: 2 }); + }, 50); + }, [initialNodes, initialEdges, setNodes, setEdges, reactFlow]); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const onNodeClick = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_event: React.MouseEvent, node: any) => { + setSelectedNode(node.id); + }, + [], + ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const selectedBlock = useMemo(() => { + if (!selectedNode) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const node = nodesState.find((n: any) => n.id === selectedNode); + return node?.data.block ?? null; + }, [selectedNode, nodesState]); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const selectedBlockName = useMemo(() => { + if (!selectedNode || selectedNode.includes("-label")) return null; + return selectedNode.replace(":", "::"); + }, [selectedNode]); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const formatInstruction = useCallback((inst: Ir.Instruction): string => { + const formatValue = (value: unknown): string => { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "boolean") return value.toString(); + + const val = value as { + kind?: string; + value?: unknown; + id?: string | number; + name?: string; + }; + if (!val.kind) return "?"; + + switch (val.kind) { + case "const": + return String(val.value || "?"); + case "temp": + return `%${val.id || "?"}`; + case "local": + return `$${val.name || "?"}`; + default: + return "?"; + } + }; + + switch (inst.kind) { + case "const": + return `${inst.dest} = ${inst.value}`; + case "binary": + return `${inst.dest} = ${formatValue(inst.left)} ${inst.op} ${formatValue(inst.right)}`; + case "unary": + return `${inst.dest} = ${inst.op}${formatValue(inst.operand)}`; + case "read": + if (inst.location === "storage" && inst.slot) { + return `${inst.dest} = storage[${formatValue(inst.slot)}]`; + } + return `${inst.dest} = read.${inst.location}`; + case "write": + if (inst.location === "storage" && inst.slot) { + return `storage[${formatValue(inst.slot)}] = ${formatValue(inst.value)}`; + } + return `write.${inst.location} = ${formatValue(inst.value)}`; + case "env": { + const envInst = inst as Ir.Instruction.Env; + switch (envInst.op) { + case "msg_sender": + return `${envInst.dest} = msg.sender`; + case "msg_value": + return `${envInst.dest} = msg.value`; + case "msg_data": + return `${envInst.dest} = msg.data`; + case "block_timestamp": + return `${envInst.dest} = block.timestamp`; + case "block_number": + return `${envInst.dest} = block.number`; + default: + return `${envInst.dest} = ${envInst.op}`; + } + } + case "hash": + return `${inst.dest} = keccak256(${formatValue(inst.value)})`; + case "cast": + return `${inst.dest} = cast ${formatValue(inst.value)} to ${inst.targetType.kind}`; + case "compute_slot": { + if (inst.slotKind === "mapping") { + const mappingInst = inst as Ir.Instruction.ComputeSlot.Mapping; + return `${mappingInst.dest} = compute_slot[mapping](${formatValue(mappingInst.base)}, ${formatValue(mappingInst.key)})`; + } else if (inst.slotKind === "array") { + const arrayInst = inst as Ir.Instruction.ComputeSlot.Array; + return `${arrayInst.dest} = compute_slot[array](${formatValue(arrayInst.base)})`; + } else if (inst.slotKind === "field") { + const fieldInst = inst as Ir.Instruction.ComputeSlot.Field; + return `${fieldInst.dest} = compute_slot[field](${formatValue(fieldInst.base)}, offset_${fieldInst.fieldOffset})`; + } + return `unknown compute_slot`; + } + default: { + const unknownInst = inst as { dest?: string; kind?: string }; + return `${unknownInst.dest || "?"} = ${unknownInst.kind || "unknown"}(...)`; + } + } + }, []); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const formatTerminator = useCallback((term: Ir.Block.Terminator): string => { + const formatValue = (value: unknown): string => { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "boolean") return value.toString(); + + const val = value as { + kind?: string; + value?: unknown; + id?: string | number; + name?: string; + }; + if (!val.kind) return "?"; + + switch (val.kind) { + case "const": + return String(val.value || "?"); + case "temp": + return `%${val.id || "?"}`; + case "local": + return `$${val.name || "?"}`; + default: + return "?"; + } + }; + + switch (term.kind) { + case "jump": + return `jump ${term.target}`; + case "branch": + return `branch ${formatValue(term.condition)} ? ${term.trueTarget} : ${term.falseTarget}`; + case "return": + return term.value ? `return ${formatValue(term.value)}` : "return void"; + case "call": { + const args = term.arguments.map(formatValue).join(", "); + const callPart = term.dest + ? `${term.dest} = call ${term.function}(${args})` + : `call ${term.function}(${args})`; + return `${callPart} -> ${term.continuation}`; + } + default: + return `unknown terminator`; + } + }, []); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const nodeTypes = useMemo(() => ({ block: BlockNodeComponent }), []); + + return ( +
+
+

Control Flow Graph

+
+
+
+ + + + +
+ {selectedBlock && selectedBlockName && ( +
+

+ Block {selectedBlockName} + +

+
+
Instructions:
+
+                {selectedBlock.instructions.map(
+                  (inst: Ir.Instruction, i: number) => (
+                    
+ {formatInstruction(inst)} +
+ ), + )} +
+ {formatTerminator(selectedBlock.terminator)} +
+
+
+
+ )} +
+
+ ); +} + +/** + * Displays a control flow graph visualization of the IR. + * + * Requires optional peer dependencies: react-flow-renderer, dagre + * + * @param props - IR module and options + * @returns CfgView element + * + * @example + * ```tsx + * + * ``` + */ +export function CfgView(props: CfgViewProps): JSX.Element { + const [loaded, setLoaded] = useState(dependenciesLoaded); + const [error, setError] = useState(null); + + useEffect(() => { + if (!loaded) { + loadDependencies() + .then((success) => { + if (success) { + setLoaded(true); + } else { + setError( + "CfgView requires react-flow-renderer and dagre packages. " + + "Please install them: npm install react-flow-renderer dagre", + ); + } + }) + .catch(() => { + setError("Failed to load CfgView dependencies"); + }); + } + }, [loaded]); + + if (error) { + return ( +
+
+

Control Flow Graph

+
+
+

+ {error} +

+
+
+ ); + } + + if (!loaded || !rfModule) { + return ( +
+
+

Control Flow Graph

+
+
+

Loading...

+
+
+ ); + } + + const { ReactFlowProvider } = rfModule; + + return ( + + + + ); +} diff --git a/packages/bugc-react/src/components/Editor.tsx b/packages/bugc-react/src/components/Editor.tsx new file mode 100644 index 000000000..47f5c7872 --- /dev/null +++ b/packages/bugc-react/src/components/Editor.tsx @@ -0,0 +1,242 @@ +/** + * Monaco editor component for editing BUG source code. + * + * Note: This component requires optional peer dependency: + * - @monaco-editor/react + */ + +import React, { useEffect, useRef, useState } from "react"; +import { registerBugLanguage } from "#utils/bugLanguage"; + +/** + * Represents a range in the source by offset and length. + */ +export interface EditorSourceRange { + /** Starting byte offset */ + offset: number; + /** Length in bytes */ + length: number; +} + +/** + * Props for Editor component. + */ +export interface EditorProps { + /** Current source code value */ + value: string; + /** Callback when source code changes */ + onChange: (value: string) => void; + /** Language mode (default: "bug") */ + language?: string; + /** Ranges to highlight in the editor */ + highlightedRanges?: EditorSourceRange[]; + /** Theme mode (default: auto-detect from document) */ + theme?: "light" | "dark" | "auto"; + /** Editor height (default: "100%") */ + height?: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let monacoEditorModule: any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let monacoModule: any; +let monacoLoaded = false; + +async function loadMonaco(): Promise { + if (monacoLoaded) { + return true; + } + + try { + monacoEditorModule = await import("@monaco-editor/react"); + monacoModule = await import("monaco-editor"); + registerBugLanguage(monacoModule); + monacoLoaded = true; + return true; + } catch { + return false; + } +} + +/** + * Monaco editor component for editing BUG source code. + * + * Supports syntax highlighting, source range highlighting, and auto-theme + * detection. + * + * Requires optional peer dependency: @monaco-editor/react + * + * @param props - Editor configuration + * @returns Editor element + * + * @example + * ```tsx + * + * ``` + */ +export function Editor({ + value, + onChange, + language = "bug", + highlightedRanges = [], + theme = "auto", + height = "100%", +}: EditorProps): JSX.Element { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editorRef = useRef(null); + const decorationsRef = useRef([]); + const [loaded, setLoaded] = useState(monacoLoaded); + const [error, setError] = useState(null); + + // Detect theme from document + const [detectedTheme, setDetectedTheme] = useState<"light" | "dark">("dark"); + useEffect(() => { + if (theme === "auto") { + const detectTheme = () => { + const dataTheme = document.documentElement.getAttribute("data-theme"); + setDetectedTheme(dataTheme === "dark" ? "dark" : "light"); + }; + detectTheme(); + + // Watch for theme changes + const observer = new MutationObserver(detectTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + } + }, [theme]); + + const effectiveTheme = theme === "auto" ? detectedTheme : theme; + const monacoTheme = effectiveTheme === "dark" ? "vs-dark" : "vs"; + + // Load Monaco + useEffect(() => { + if (!loaded) { + loadMonaco() + .then((success) => { + if (success) { + setLoaded(true); + } else { + setError( + "Editor requires @monaco-editor/react package. " + + "Please install it: npm install @monaco-editor/react", + ); + } + }) + .catch(() => { + setError("Failed to load Monaco editor"); + }); + } + }, [loaded]); + + // Handle decorations for highlighted ranges + useEffect(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + + const model = editor.getModel(); + if (!model) { + return; + } + + // Clear previous decorations + decorationsRef.current = editor.deltaDecorations( + decorationsRef.current, + [], + ); + + // Add new decorations for all highlighted ranges + if (highlightedRanges.length > 0) { + const decorations = highlightedRanges.map((range, index) => { + const startPosition = model.getPositionAt(range.offset); + const endPosition = model.getPositionAt(range.offset + range.length); + + // First range is "primary", rest are "alternative" + const isPrimary = index === 0; + const className = isPrimary + ? "opcode-hover-highlight" + : "opcode-hover-highlight-alternative"; + const inlineClassName = isPrimary + ? "opcode-hover-highlight-inline" + : "opcode-hover-highlight-alternative-inline"; + + return { + range: { + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column, + }, + options: { + className, + isWholeLine: false, + inlineClassName, + }, + }; + }); + + decorationsRef.current = editor.deltaDecorations([], decorations); + + // Scroll to the first (primary) highlighted range + const firstRange = highlightedRanges[0]; + const startPosition = model.getPositionAt(firstRange.offset); + const endPosition = model.getPositionAt( + firstRange.offset + firstRange.length, + ); + editor.revealRangeInCenter({ + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: endPosition.lineNumber, + endColumn: endPosition.column, + }); + } + }, [highlightedRanges]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleEditorDidMount = (editor: any) => { + editorRef.current = editor; + }; + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!loaded || !monacoEditorModule) { + return
Loading editor...
; + } + + const MonacoEditor = monacoEditorModule.default; + + return ( + onChange(val || "")} + onMount={handleEditorDidMount} + options={{ + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: "on", + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + }} + /> + ); +} diff --git a/packages/bugc-react/src/components/EthdebugTooltip.css b/packages/bugc-react/src/components/EthdebugTooltip.css new file mode 100644 index 000000000..d758d9d57 --- /dev/null +++ b/packages/bugc-react/src/components/EthdebugTooltip.css @@ -0,0 +1,76 @@ +/** + * Styles for EthdebugTooltip component. + */ + +@import "./variables.css"; + +.ethdebug-tooltip { + position: fixed; + z-index: 1000; + background-color: var(--bugc-tooltip-bg); + border: 1px solid var(--bugc-tooltip-border); + border-radius: 4px; + padding: 0.5rem; + max-width: 600px; + max-height: 400px; + overflow: auto; + pointer-events: none; + box-shadow: 0 4px 12px var(--bugc-tooltip-shadow); +} + +.ethdebug-tooltip.pinned { + pointer-events: auto; + border-color: var(--bugc-tooltip-pinned-border); + box-shadow: 0 4px 16px var(--bugc-tooltip-pinned-shadow); +} + +.ethdebug-tooltip pre { + margin: 0; + font-family: "Courier New", monospace; + font-size: 0.75rem; + color: var(--bugc-text-code); + white-space: pre-wrap; + word-break: break-word; +} + +.debug-info-icon { + color: var(--bugc-accent-blue); + cursor: pointer; + padding: 0.125rem 0.25rem; + border-radius: 3px; + transition: all 0.15s ease; + user-select: none; + display: inline-block; + min-width: 1.2rem; + text-align: center; +} + +.debug-info-icon:hover { + background-color: var(--bugc-accent-blue-bg); +} + +.debug-info-spacer { + display: inline-block; + min-width: 1.2rem; + padding: 0.125rem 0.25rem; +} + +.tooltip-close-btn { + position: absolute; + top: 0.25rem; + right: 0.25rem; + background: transparent; + border: none; + color: var(--bugc-text-code); + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + padding: 0.125rem 0.25rem; + border-radius: 3px; + transition: all 0.15s ease; +} + +.tooltip-close-btn:hover { + background-color: var(--bugc-bg-hover); + color: var(--bugc-text-primary); +} diff --git a/packages/bugc-react/src/components/EthdebugTooltip.tsx b/packages/bugc-react/src/components/EthdebugTooltip.tsx new file mode 100644 index 000000000..38b7d00a8 --- /dev/null +++ b/packages/bugc-react/src/components/EthdebugTooltip.tsx @@ -0,0 +1,150 @@ +/** + * Tooltip component for displaying ethdebug debug information. + */ + +import React, { useRef, useEffect } from "react"; +import type { TooltipData } from "#types"; + +/** + * Props for EthdebugTooltip component. + */ +export interface EthdebugTooltipProps { + /** Tooltip data to display, or null to hide */ + tooltip: TooltipData | null; + /** Callback to update tooltip position */ + onUpdate?: (tooltip: TooltipData) => void; + /** Callback when tooltip is closed */ + onClose?: () => void; +} + +/** + * Displays debug information in a floating tooltip. + * + * Automatically adjusts position to stay within viewport bounds. + * When pinned, the tooltip stays in place and can be closed manually. + * + * @param props - Tooltip data and callbacks + * @returns Tooltip element or null + * + * @example + * ```tsx + * const { tooltip, setTooltip, closeTooltip } = useEthdebugTooltip(); + * + * + * ``` + */ +export function EthdebugTooltip({ + tooltip, + onUpdate, + onClose, +}: EthdebugTooltipProps): JSX.Element | null { + const tooltipRef = useRef(null); + + useEffect(() => { + if (tooltip && tooltipRef.current) { + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let { x, y } = tooltip; + + // Adjust horizontal position if tooltip goes off right edge + if (x + tooltipRect.width > viewportWidth) { + x = viewportWidth - tooltipRect.width - 10; + } + + // Adjust horizontal position if tooltip goes off left edge + if (x < 10) { + x = 10; + } + + // Adjust vertical position if tooltip goes off bottom edge + if (y + tooltipRect.height > viewportHeight) { + y = viewportHeight - tooltipRect.height - 10; + } + + // Adjust vertical position if tooltip goes off top edge + if (y < 10) { + y = 10; + } + + // Update position if it changed + if (x !== tooltip.x || y !== tooltip.y) { + onUpdate?.({ ...tooltip, x, y }); + } + } + }, [tooltip, onUpdate]); + + if (!tooltip) { + return null; + } + + return ( +
+ {tooltip.pinned && ( + + )} +
{tooltip.content}
+
+ ); +} + +/** + * Props for DebugInfoIcon component. + */ +export interface DebugInfoIconProps { + /** Callback when icon is hovered */ + onMouseEnter: (e: React.MouseEvent) => void; + /** Callback when hover ends */ + onMouseLeave: () => void; + /** Callback when icon is clicked (for pinning) */ + onClick: (e: React.MouseEvent) => void; +} + +/** + * Small icon indicating debug information is available. + * + * @param props - Event handlers + * @returns Icon element + */ +export function DebugInfoIcon({ + onMouseEnter, + onMouseLeave, + onClick, +}: DebugInfoIconProps): JSX.Element { + return ( + + ℹ + + ); +} + +/** + * Spacer element to maintain alignment when no debug icon is shown. + */ +export function DebugInfoSpacer(): JSX.Element { + return ; +} diff --git a/packages/bugc-react/src/components/IrView.css b/packages/bugc-react/src/components/IrView.css new file mode 100644 index 000000000..e449ed737 --- /dev/null +++ b/packages/bugc-react/src/components/IrView.css @@ -0,0 +1,147 @@ +/** + * Styles for IrView component. + */ + +@import "./variables.css"; + +.ir-view { + height: 100%; + overflow: auto; +} + +.ir-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background-color: var(--bugc-bg-secondary); + border-bottom: 1px solid var(--bugc-border-primary); + position: sticky; + top: 0; + z-index: 1; +} + +.ir-header h3 { + margin: 0; + font-size: 1rem; + color: var(--bugc-text-primary); +} + +.ir-stats { + display: flex; + gap: 1rem; + font-size: 0.813rem; + color: var(--bugc-text-secondary); +} + +.ir-content { + padding: 1rem; + font-family: "Consolas", "Monaco", "Courier New", monospace; + font-size: 0.875rem; + line-height: 1.6; + color: var(--bugc-text-primary); +} + +.section-label { + color: var(--bugc-syntax-comment); + font-weight: bold; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + font-size: 0.938rem; +} + +.section-label:first-child { + margin-top: 0; +} + +.ir-function { + margin-bottom: 2rem; +} + +.function-header h4 { + margin: 0 0 0.5rem 0; + color: var(--bugc-syntax-function); + font-size: 1rem; + font-weight: bold; +} + +.ir-block { + margin-bottom: 1rem; + padding-left: 1rem; +} + +.block-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; + color: var(--bugc-syntax-type); +} + +.entry-badge { + background-color: var(--bugc-accent-green-bg); + color: var(--bugc-accent-green); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.688rem; + font-weight: bold; +} + +.block-body { + padding-left: 1rem; +} + +.ir-instruction, +.ir-terminator, +.ir-phi { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.125rem 0; + line-height: 1.6; +} + +.ir-instruction:hover, +.ir-terminator:hover, +.ir-phi:hover { + background-color: var(--bugc-bg-hover); +} + +.instruction-operation, +.terminator-operation, +.phi-operation { + flex: 1; +} + +.ir-terminator { + color: var(--bugc-syntax-terminator); + font-weight: 500; +} + +.ir-phi { + color: var(--bugc-syntax-phi); + font-style: italic; +} + +.hoverable-part { + display: inline; + transition: background-color 0.15s ease; +} + +.hoverable-part.has-debug { + cursor: pointer; + border-bottom: 1px dotted var(--bugc-accent-blue); + border-bottom-color: rgba(86, 156, 214, 0.4); +} + +.hoverable-part.has-debug:hover { + background-color: var(--bugc-accent-blue-bg); + border-bottom-color: var(--bugc-accent-blue); +} + +.debug-info-icon.inline { + display: inline; + margin-left: 0.25rem; + font-size: 0.75rem; + vertical-align: super; +} diff --git a/packages/bugc-react/src/components/IrView.tsx b/packages/bugc-react/src/components/IrView.tsx new file mode 100644 index 000000000..fd38f1e1b --- /dev/null +++ b/packages/bugc-react/src/components/IrView.tsx @@ -0,0 +1,842 @@ +/** + * IrView component for displaying BUG compiler IR. + */ + +import React, { useMemo } from "react"; +import { Ir } from "@ethdebug/bugc"; +import type { SourceRange } from "#types"; +import { + extractInstructionDebug, + extractTerminatorDebug, + extractPhiDebug, + formatMultiLevelDebug, + extractAllSourceRanges, + extractOperandSourceRanges, +} from "#utils/irDebugUtils"; +import { useEthdebugTooltip } from "#hooks/useEthdebugTooltip"; +import { EthdebugTooltip } from "./EthdebugTooltip.js"; + +/** + * Props for IrView component. + */ +export interface IrViewProps { + /** The IR module to display */ + ir: Ir.Module; + /** Callback when hovering over IR elements with source ranges */ + onOpcodeHover?: (ranges: SourceRange[]) => void; +} + +interface HoverablePartData { + text: string; + ranges: SourceRange[]; + className?: string; +} + +function HoverablePart({ + part, + onHover, + onLeave, + onDebugIconHover, + showDebugIcon, +}: { + part: HoverablePartData; + onHover: (ranges: SourceRange[]) => void; + onLeave: () => void; + onDebugIconHover?: (e: React.MouseEvent) => void; + showDebugIcon?: boolean; +}): JSX.Element { + return ( + 0 ? "has-debug" : ""}`} + onMouseEnter={() => onHover(part.ranges)} + onMouseLeave={onLeave} + > + {part.text} + {showDebugIcon && part.ranges.length > 0 && onDebugIconHover && ( + + ℹ + + )} + + ); +} + +function formatValue(value: Ir.Value | bigint | string | boolean): string { + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "string") { + if (value.startsWith("0x")) { + return value; + } + return JSON.stringify(value); + } + if (typeof value === "boolean") { + return value.toString(); + } + + switch (value.kind) { + case "const": + return formatValue(value.value); + case "temp": + return `%${value.id}`; + default: + return "?"; + } +} + +function formatType(type: Ir.Type): string { + return type.kind; +} + +function formatDest(dest: string, type?: Ir.Type): string { + const prefix = dest.startsWith("t") ? "%" : "^"; + const formattedDest = `${prefix}${dest}`; + return type ? `${formattedDest}: ${formatType(type)}` : formattedDest; +} + +function InstructionRenderer({ + instruction, + onHover, + onLeave, + showTooltip, + pinTooltip, + hideTooltip, +}: { + instruction: Ir.Instruction; + onHover: (ranges: SourceRange[]) => void; + onLeave: () => void; + showTooltip: (e: React.MouseEvent, content: string) => void; + pinTooltip: (e: React.MouseEvent, content: string) => void; + hideTooltip: () => void; +}): JSX.Element { + const debugInfo = extractInstructionDebug(instruction); + const operationRanges = debugInfo.operation?.context + ? extractAllSourceRanges({ operation: debugInfo.operation, operands: [] }) + : []; + + const parts: (HoverablePartData | string)[] = []; + + const addOperand = (label: string, text: string, className?: string) => { + const ranges = extractOperandSourceRanges(debugInfo, label); + parts.push({ text, ranges, className }); + }; + + const add = (text: string) => { + parts.push(text); + }; + + switch (instruction.kind) { + case "const": + add(`${formatDest(instruction.dest, instruction.type)} = const `); + addOperand("value", formatValue(instruction.value)); + break; + + case "allocate": + add( + `${formatDest(instruction.dest, Ir.Type.Scalar.uint256)} = allocate.${instruction.location}, size=`, + ); + addOperand("size", formatValue(instruction.size)); + break; + + case "binary": + add(`${formatDest(instruction.dest)} = ${instruction.op} `); + addOperand("left", formatValue(instruction.left)); + add(", "); + addOperand("right", formatValue(instruction.right)); + break; + + case "unary": + add(`${formatDest(instruction.dest)} = ${instruction.op} `); + addOperand("operand", formatValue(instruction.operand)); + break; + + case "env": + add(`${formatDest(instruction.dest)} = env ${instruction.op}`); + break; + + case "hash": + add(`${formatDest(instruction.dest)} = hash `); + addOperand("value", formatValue(instruction.value)); + break; + + case "cast": + add(`${formatDest(instruction.dest, instruction.targetType)} = cast `); + addOperand("value", formatValue(instruction.value)); + add(` to ${formatType(instruction.targetType)}`); + break; + + case "length": + add(`${formatDest(instruction.dest)} = length `); + addOperand("object", formatValue(instruction.object)); + break; + + case "compute_slot": { + const base = formatValue(instruction.base); + add(`${formatDest(instruction.dest, Ir.Type.Scalar.uint256)} = slot[`); + addOperand("base", base); + if ("key" in instruction && instruction.key) { + add("].mapping["); + addOperand("key", formatValue(instruction.key)); + add("]"); + } else if ( + "slotKind" in instruction && + instruction.slotKind === "array" + ) { + add("].array"); + } else if ("fieldOffset" in instruction) { + add(`].field[${instruction.fieldOffset}]`); + } else { + add("]"); + } + break; + } + + case "compute_offset": { + const base = formatValue(instruction.base); + const dest = instruction.dest.startsWith("t") + ? `%${instruction.dest}` + : instruction.dest; + add(`${dest} = offset[`); + addOperand("base", base); + if ("index" in instruction && instruction.index) { + if (instruction.stride === 32) { + add("].array["); + addOperand("index", formatValue(instruction.index)); + add("]"); + } else { + add("].array[index: "); + addOperand("index", formatValue(instruction.index)); + add(`, stride: ${instruction.stride}]`); + } + } else if ("offset" in instruction && instruction.offset) { + add("].byte["); + addOperand("offset", formatValue(instruction.offset)); + add("]"); + } else if ("fieldOffset" in instruction) { + add(`].field[${instruction.fieldOffset}]`); + } else { + add("]"); + } + break; + } + + case "read": { + const location = instruction.location; + const isDefaultOffset = + !instruction.offset || + (instruction.offset.kind === "const" && + instruction.offset.value === 0n); + const isDefaultLength = + !instruction.length || + (instruction.length.kind === "const" && + instruction.length.value === 32n); + + add(`${formatDest(instruction.dest, instruction.type)} = `); + + if (location === "storage" || location === "transient") { + const slot = instruction.slot ? formatValue(instruction.slot) : "0"; + if (isDefaultOffset && isDefaultLength) { + add(`${location}[`); + addOperand("slot", slot); + add("*]"); + } else { + add(`${location}[slot: `); + addOperand("slot", slot); + if (!isDefaultOffset && instruction.offset) { + add(", offset: "); + addOperand("offset", formatValue(instruction.offset)); + } + if (!isDefaultLength && instruction.length) { + add(", length: "); + addOperand("length", formatValue(instruction.length)); + } + add("]"); + } + } else { + if (instruction.offset) { + const offset = formatValue(instruction.offset); + if (isDefaultLength) { + add(`${location}[`); + addOperand("offset", offset); + add("*]"); + } else { + add(`${location}[offset: `); + addOperand("offset", offset); + const length = instruction.length + ? formatValue(instruction.length) + : "32"; + add(", length: "); + addOperand("length", length); + add("]"); + } + } else { + add(`${location}[]`); + } + } + break; + } + + case "write": { + const location = instruction.location; + const value = formatValue(instruction.value); + const isDefaultOffset = + !instruction.offset || + (instruction.offset.kind === "const" && + instruction.offset.value === 0n); + const isDefaultLength = + !instruction.length || + (instruction.length.kind === "const" && + instruction.length.value === 32n); + + if (location === "storage" || location === "transient") { + const slot = instruction.slot ? formatValue(instruction.slot) : "0"; + if (isDefaultOffset && isDefaultLength) { + add(`${location}[`); + addOperand("slot", slot); + add("*] = "); + } else { + add(`${location}[slot: `); + addOperand("slot", slot); + if (!isDefaultOffset && instruction.offset) { + add(", offset: "); + addOperand("offset", formatValue(instruction.offset)); + } + if (!isDefaultLength && instruction.length) { + add(", length: "); + addOperand("length", formatValue(instruction.length)); + } + add("] = "); + } + } else { + if (instruction.offset) { + const offset = formatValue(instruction.offset); + if (isDefaultLength) { + add(`${location}[`); + addOperand("offset", offset); + add("*] = "); + } else { + add(`${location}[offset: `); + addOperand("offset", offset); + const length = instruction.length + ? formatValue(instruction.length) + : "32"; + add(", length: "); + addOperand("length", length); + add("] = "); + } + } else { + add(`${location}[] = `); + } + } + addOperand("value", value); + break; + } + + default: + add(`; unknown instruction: ${(instruction as { kind?: string }).kind}`); + } + + const hasAnyDebug = + operationRanges.length > 0 || + debugInfo.operands.some((op) => op.debug?.context); + + const handleDebugIconHover = (e: React.MouseEvent) => { + const content = formatMultiLevelDebug(debugInfo); + showTooltip(e, content); + }; + + const handleDebugIconClick = (e: React.MouseEvent) => { + const content = formatMultiLevelDebug(debugInfo); + pinTooltip(e, content); + }; + + return ( +
+ {hasAnyDebug && ( + + ℹ + + )} + {!hasAnyDebug && } + onHover(operationRanges)} + onMouseLeave={onLeave} + > + {parts.map((part, idx) => + typeof part === "string" ? ( + {part} + ) : ( + + ), + )} + +
+ ); +} + +function TerminatorRenderer({ + terminator, + onHover, + onLeave, + showTooltip, + pinTooltip, + hideTooltip, +}: { + terminator: Ir.Block.Terminator; + onHover: (ranges: SourceRange[]) => void; + onLeave: () => void; + showTooltip: (e: React.MouseEvent, content: string) => void; + pinTooltip: (e: React.MouseEvent, content: string) => void; + hideTooltip: () => void; +}): JSX.Element { + const debugInfo = extractTerminatorDebug(terminator); + const operationRanges = debugInfo.operation?.context + ? extractAllSourceRanges({ operation: debugInfo.operation, operands: [] }) + : []; + + const parts: (HoverablePartData | string)[] = []; + + const addOperand = (label: string, text: string, className?: string) => { + const ranges = extractOperandSourceRanges(debugInfo, label); + parts.push({ text, ranges, className }); + }; + + const add = (text: string) => { + parts.push(text); + }; + + switch (terminator.kind) { + case "jump": + add(`jump ${terminator.target}`); + break; + + case "branch": + add("branch "); + addOperand("condition", formatValue(terminator.condition)); + add(` ? ${terminator.trueTarget} : ${terminator.falseTarget}`); + break; + + case "return": + if (terminator.value) { + add("return "); + addOperand("value", formatValue(terminator.value)); + } else { + add("return void"); + } + break; + + case "call": + if (terminator.dest) { + add(`${terminator.dest} = `); + } + add(`call ${terminator.function}(`); + terminator.arguments.forEach((arg, idx) => { + if (idx > 0) add(", "); + addOperand(`arg[${idx}]`, formatValue(arg)); + }); + add(`) -> ${terminator.continuation}`); + break; + } + + const hasAnyDebug = + operationRanges.length > 0 || + debugInfo.operands.some((op) => op.debug?.context); + + const handleDebugIconHover = (e: React.MouseEvent) => { + const content = formatMultiLevelDebug(debugInfo); + showTooltip(e, content); + }; + + const handleDebugIconClick = (e: React.MouseEvent) => { + const content = formatMultiLevelDebug(debugInfo); + pinTooltip(e, content); + }; + + return ( +
+ {hasAnyDebug && ( + + ℹ + + )} + {!hasAnyDebug && } + onHover(operationRanges)} + onMouseLeave={onLeave} + > + {parts.map((part, idx) => + typeof part === "string" ? ( + {part} + ) : ( + + ), + )} + +
+ ); +} + +function PhiRenderer({ + phi, + onHover, + onLeave, + showTooltip, + pinTooltip, + hideTooltip, +}: { + phi: Ir.Block.Phi; + onHover: (ranges: SourceRange[]) => void; + onLeave: () => void; + showTooltip: (e: React.MouseEvent, content: string) => void; + pinTooltip: (e: React.MouseEvent, content: string) => void; + hideTooltip: () => void; +}): JSX.Element { + const debugInfo = extractPhiDebug(phi); + const operationRanges = debugInfo.operation?.context + ? extractAllSourceRanges({ operation: debugInfo.operation, operands: [] }) + : []; + + const hasAnyDebug = + operationRanges.length > 0 || + debugInfo.operands.some((op) => op.debug?.context); + + const handleDebugIconHover = (e: React.MouseEvent) => { + const content = formatMultiLevelDebug(debugInfo); + showTooltip(e, content); + }; + + const handleDebugIconClick = (e: React.MouseEvent) => { + const content = formatMultiLevelDebug(debugInfo); + pinTooltip(e, content); + }; + + const parts: (HoverablePartData | string)[] = []; + const add = (text: string) => parts.push(text); + + const dest = phi.dest.startsWith("t") ? `%${phi.dest}` : `^${phi.dest}`; + const typeStr = phi.type ? `: ${formatType(phi.type)}` : ""; + add(`${dest}${typeStr} = phi `); + + const sources = Array.from(phi.sources.entries()); + sources.forEach(([pred, value], idx) => { + if (idx > 0) add(", "); + + const label = `from ${pred}`; + const ranges = extractOperandSourceRanges(debugInfo, label); + parts.push({ + text: `[${pred}: ${formatValue(value)}]`, + ranges, + }); + }); + + return ( +
+ {hasAnyDebug && ( + + ℹ + + )} + {!hasAnyDebug && } + onHover(operationRanges)} + onMouseLeave={onLeave} + > + {parts.map((part, idx) => + typeof part === "string" ? ( + {part} + ) : ( + + ), + )} + +
+ ); +} + +function BlockRenderer({ + blockId, + block, + isEntry, + onHover, + onLeave, + showTooltip, + pinTooltip, + hideTooltip, +}: { + blockId: string; + block: Ir.Block; + isEntry: boolean; + onHover: (ranges: SourceRange[]) => void; + onLeave: () => void; + showTooltip: (e: React.MouseEvent, content: string) => void; + pinTooltip: (e: React.MouseEvent, content: string) => void; + hideTooltip: () => void; +}): JSX.Element { + return ( +
+
+ {blockId}: + {isEntry && entry} +
+
+ {block.phis.map((phi, idx) => ( + + ))} + {block.instructions.map((instruction, idx) => ( + + ))} + +
+
+ ); +} + +function FunctionRenderer({ + name, + func, + onHover, + onLeave, + showTooltip, + pinTooltip, + hideTooltip, +}: { + name: string; + func: Ir.Function; + onHover: (ranges: SourceRange[]) => void; + onLeave: () => void; + showTooltip: (e: React.MouseEvent, content: string) => void; + pinTooltip: (e: React.MouseEvent, content: string) => void; + hideTooltip: () => void; +}): JSX.Element { + const sortedBlocks = useMemo(() => { + const result: [string, Ir.Block][] = []; + const visited = new Set(); + const tempMarked = new Set(); + + const visit = (blockId: string) => { + if (tempMarked.has(blockId)) return; + if (visited.has(blockId)) return; + + tempMarked.add(blockId); + + const block = func.blocks.get(blockId); + if (!block) return; + + const term = block.terminator; + if (term.kind === "jump") { + visit(term.target); + } else if (term.kind === "branch") { + visit(term.trueTarget); + visit(term.falseTarget); + } else if (term.kind === "call") { + visit(term.continuation); + } + + tempMarked.delete(blockId); + visited.add(blockId); + result.unshift([blockId, block]); + }; + + visit(func.entry); + + for (const [blockId, block] of func.blocks) { + if (!visited.has(blockId)) { + result.push([blockId, block]); + } + } + + return result; + }, [func]); + + return ( +
+
+

{name}:

+
+ {sortedBlocks.map(([blockId, block]) => ( + + ))} +
+ ); +} + +/** + * Displays the IR (Intermediate Representation) from a BUG compilation. + * + * Shows functions, blocks, instructions, and terminators with interactive + * source highlighting. + * + * @param props - IR module and callbacks + * @returns IrView element + * + * @example + * ```tsx + * setHighlightedRanges(ranges)} + * /> + * ``` + */ +export function IrView({ ir, onOpcodeHover }: IrViewProps): JSX.Element { + const { + tooltip, + setTooltip, + showTooltip, + pinTooltip, + hideTooltip, + closeTooltip, + } = useEthdebugTooltip(); + + const handleHover = (ranges: SourceRange[]) => { + onOpcodeHover?.(ranges); + }; + + const handleLeave = () => { + onOpcodeHover?.([]); + }; + + const mainBlocks = ir.main.blocks.size; + const createBlocks = ir.create?.blocks.size || 0; + const userFunctionCount = ir.functions?.size || 0; + let userFunctionBlocks = 0; + if (ir.functions) { + for (const func of ir.functions.values()) { + userFunctionBlocks += func.blocks.size; + } + } + + return ( +
+
+

IR

+
+ {userFunctionCount > 0 && ( + + Functions: {userFunctionCount} ({userFunctionBlocks} blocks) + + )} + {ir.create && Create: {createBlocks} blocks} + Main: {mainBlocks} blocks +
+
+
+ {ir.functions && ir.functions.size > 0 && ( + <> +
User Functions:
+ {Array.from(ir.functions.entries()).map(([name, func]) => ( + + ))} + + )} + {ir.create && ( + <> +
Constructor:
+ + + )} +
Main (Runtime):
+ +
+ +
+ ); +} diff --git a/packages/bugc-react/src/components/index.ts b/packages/bugc-react/src/components/index.ts new file mode 100644 index 000000000..1eb6ca4ff --- /dev/null +++ b/packages/bugc-react/src/components/index.ts @@ -0,0 +1,21 @@ +/** + * Component exports for @ethdebug/bugc-react. + */ + +export { + EthdebugTooltip, + DebugInfoIcon, + DebugInfoSpacer, + type EthdebugTooltipProps, + type DebugInfoIconProps, +} from "./EthdebugTooltip.js"; + +export { BytecodeView, type BytecodeViewProps } from "./BytecodeView.js"; + +export { AstView, type AstViewProps } from "./AstView.js"; + +export { IrView, type IrViewProps } from "./IrView.js"; + +export { CfgView, type CfgViewProps } from "./CfgView.js"; + +export { Editor, type EditorProps, type EditorSourceRange } from "./Editor.js"; diff --git a/packages/bugc-react/src/components/variables.css b/packages/bugc-react/src/components/variables.css new file mode 100644 index 000000000..490510c66 --- /dev/null +++ b/packages/bugc-react/src/components/variables.css @@ -0,0 +1,108 @@ +/** + * CSS custom properties for @ethdebug/bugc-react components. + * + * These variables provide theme-aware styling that integrates with + * Docusaurus/Infima theming. + */ + +:root { + /* Background colors */ + --bugc-bg-primary: #ffffff; + --bugc-bg-secondary: #f5f6f7; + --bugc-bg-code: #f5f6f7; + --bugc-bg-hover: rgba(0, 0, 0, 0.05); + + /* Border colors */ + --bugc-border-primary: #dadde1; + --bugc-border-secondary: #eaecef; + + /* Text colors */ + --bugc-text-primary: #1c1e21; + --bugc-text-secondary: #606770; + --bugc-text-muted: #898989; + --bugc-text-code: #1c1e21; + + /* Syntax highlighting - light theme */ + --bugc-syntax-keyword: #0550ae; + --bugc-syntax-type: #6f42c1; + --bugc-syntax-function: #8250df; + --bugc-syntax-string: #0a3069; + --bugc-syntax-number: #0550ae; + --bugc-syntax-comment: #6e7781; + --bugc-syntax-opcode: #116329; + --bugc-syntax-address: #6e7781; + --bugc-syntax-terminator: #cf222e; + --bugc-syntax-phi: #0550ae; + + /* Accent colors */ + --bugc-accent-blue: #0969da; + --bugc-accent-blue-bg: rgba(9, 105, 218, 0.1); + --bugc-accent-green: #1a7f37; + --bugc-accent-green-bg: rgba(26, 127, 55, 0.1); + --bugc-accent-red: #cf222e; + --bugc-accent-purple: #8250df; + + /* Tooltip */ + --bugc-tooltip-bg: #ffffff; + --bugc-tooltip-border: #dadde1; + --bugc-tooltip-shadow: rgba(0, 0, 0, 0.15); + --bugc-tooltip-pinned-border: #0969da; + --bugc-tooltip-pinned-shadow: rgba(9, 105, 218, 0.2); + + /* CFG specific */ + --bugc-cfg-node-bg: #ffffff; + --bugc-cfg-node-border: #0969da; + --bugc-cfg-entry-border: #1a7f37; + --bugc-cfg-entry-bg: #dafbe1; +} + +[data-theme="dark"] { + /* Background colors */ + --bugc-bg-primary: #1e1e1e; + --bugc-bg-secondary: #2d2d30; + --bugc-bg-code: #2d2d30; + --bugc-bg-hover: rgba(255, 255, 255, 0.05); + + /* Border colors */ + --bugc-border-primary: #3e3e42; + --bugc-border-secondary: #454545; + + /* Text colors */ + --bugc-text-primary: #cccccc; + --bugc-text-secondary: #969696; + --bugc-text-muted: #858585; + --bugc-text-code: #d4d4d4; + + /* Syntax highlighting - dark theme */ + --bugc-syntax-keyword: #569cd6; + --bugc-syntax-type: #4ec9b0; + --bugc-syntax-function: #dcdcaa; + --bugc-syntax-string: #ce9178; + --bugc-syntax-number: #b5cea8; + --bugc-syntax-comment: #6a9955; + --bugc-syntax-opcode: #4ec9b0; + --bugc-syntax-address: #858585; + --bugc-syntax-terminator: #c586c0; + --bugc-syntax-phi: #9cdcfe; + + /* Accent colors */ + --bugc-accent-blue: #569cd6; + --bugc-accent-blue-bg: rgba(86, 156, 214, 0.15); + --bugc-accent-green: #4ec9b0; + --bugc-accent-green-bg: rgba(78, 201, 176, 0.2); + --bugc-accent-red: #f14c4c; + --bugc-accent-purple: #c586c0; + + /* Tooltip */ + --bugc-tooltip-bg: #1e1e1e; + --bugc-tooltip-border: #3e3e42; + --bugc-tooltip-shadow: rgba(0, 0, 0, 0.4); + --bugc-tooltip-pinned-border: #569cd6; + --bugc-tooltip-pinned-shadow: rgba(86, 156, 214, 0.3); + + /* CFG specific */ + --bugc-cfg-node-bg: #2d2d30; + --bugc-cfg-node-border: #569cd6; + --bugc-cfg-entry-border: #4ec9b0; + --bugc-cfg-entry-bg: rgba(78, 201, 176, 0.15); +} diff --git a/packages/bugc-react/src/hooks/index.ts b/packages/bugc-react/src/hooks/index.ts new file mode 100644 index 000000000..e0c7d142d --- /dev/null +++ b/packages/bugc-react/src/hooks/index.ts @@ -0,0 +1,5 @@ +/** + * Hook exports for @ethdebug/bugc-react. + */ + +export { useEthdebugTooltip } from "./useEthdebugTooltip.js"; diff --git a/packages/bugc-react/src/hooks/useEthdebugTooltip.ts b/packages/bugc-react/src/hooks/useEthdebugTooltip.ts new file mode 100644 index 000000000..336b1a688 --- /dev/null +++ b/packages/bugc-react/src/hooks/useEthdebugTooltip.ts @@ -0,0 +1,109 @@ +/** + * Hook for managing ethdebug tooltip state. + */ + +import React, { useState, useCallback } from "react"; +import type { TooltipData } from "#types"; + +/** + * State and handlers returned by useEthdebugTooltip. + */ +export interface UseEthdebugTooltipResult { + /** Current tooltip data, if any */ + tooltip: TooltipData | null; + /** Set tooltip data directly */ + setTooltip: React.Dispatch>; + /** Show tooltip at element position */ + showTooltip: (e: React.MouseEvent, content: string) => void; + /** Show and pin tooltip at element position */ + pinTooltip: (e: React.MouseEvent, content: string) => void; + /** Hide tooltip if not pinned */ + hideTooltip: () => void; + /** Close tooltip regardless of pinned state */ + closeTooltip: () => void; +} + +/** + * Hook for managing tooltip display state. + * + * Provides state and handlers for showing, hiding, pinning, and unpinning + * tooltips in debug information displays. + * + * @returns Tooltip state and control functions + * + * @example + * ```tsx + * function MyComponent() { + * const { tooltip, setTooltip, showTooltip, hideTooltip, closeTooltip } = + * useEthdebugTooltip(); + * + * return ( + *
+ * showTooltip(e, "Debug info here")} + * onMouseLeave={hideTooltip} + * onClick={(e) => pinTooltip(e, "Debug info here")} + * > + * Hover me + * + * + *
+ * ); + * } + * ``` + */ +export function useEthdebugTooltip(): UseEthdebugTooltipResult { + const [tooltip, setTooltip] = useState(null); + + const showTooltip = useCallback( + (e: React.MouseEvent, content: string) => { + const rect = e.currentTarget.getBoundingClientRect(); + setTooltip({ + content, + x: rect.left, + y: rect.bottom, + pinned: false, + }); + }, + [], + ); + + const pinTooltip = useCallback( + (e: React.MouseEvent, content: string) => { + const rect = e.currentTarget.getBoundingClientRect(); + setTooltip({ + content, + x: rect.left, + y: rect.bottom, + pinned: true, + }); + }, + [], + ); + + const hideTooltip = useCallback(() => { + setTooltip((current) => { + if (current?.pinned) { + return current; + } + return null; + }); + }, []); + + const closeTooltip = useCallback(() => { + setTooltip(null); + }, []); + + return { + tooltip, + setTooltip, + showTooltip, + pinTooltip, + hideTooltip, + closeTooltip, + }; +} diff --git a/packages/bugc-react/src/index.ts b/packages/bugc-react/src/index.ts new file mode 100644 index 000000000..b4151f575 --- /dev/null +++ b/packages/bugc-react/src/index.ts @@ -0,0 +1,73 @@ +/** + * @ethdebug/bugc-react + * + * React components for visualizing BUG compiler output. + * + * @packageDocumentation + */ + +// Types +export type { + SourceRange, + BytecodeOutput, + SuccessfulCompileResult, + FailedCompileResult, + CompileResult, + TooltipData, + TooltipState, +} from "./types.js"; + +// Hooks +export { useEthdebugTooltip } from "#hooks/useEthdebugTooltip"; +export type { UseEthdebugTooltipResult } from "#hooks/useEthdebugTooltip"; + +// Components +export { + EthdebugTooltip, + DebugInfoIcon, + DebugInfoSpacer, + type EthdebugTooltipProps, + type DebugInfoIconProps, +} from "#components/EthdebugTooltip"; + +export { BytecodeView, type BytecodeViewProps } from "#components/BytecodeView"; + +export { AstView, type AstViewProps } from "#components/AstView"; + +export { IrView, type IrViewProps } from "#components/IrView"; + +export { CfgView, type CfgViewProps } from "#components/CfgView"; + +export { + Editor, + type EditorProps, + type EditorSourceRange, +} from "#components/Editor"; + +// Utilities +export { + // Debug utilities + extractSourceRange, + formatDebugContext, + hasSourceRange, + // IR debug utilities + extractInstructionDebug, + extractTerminatorDebug, + extractPhiDebug, + formatMultiLevelDebug, + extractAllSourceRanges, + extractOperandSourceRanges, + type MultiLevelDebugInfo, + // Bytecode utilities + formatBytecode, + getOpcodeName, + OPCODES, + // BUG language utilities + bugKeywords, + bugTypeKeywords, + bugOperators, + bugLanguageId, + bugMonarchTokensProvider, + bugLanguageConfiguration, + registerBugLanguage, +} from "#utils/index"; diff --git a/packages/bugc-react/src/types.ts b/packages/bugc-react/src/types.ts new file mode 100644 index 000000000..939524075 --- /dev/null +++ b/packages/bugc-react/src/types.ts @@ -0,0 +1,87 @@ +/** + * Type definitions for @ethdebug/bugc-react components. + */ + +import type { Ast, Ir, Evm } from "@ethdebug/bugc"; + +/** + * Represents a source range in the original code. + */ +export interface SourceRange { + /** Starting byte offset */ + offset: number; + /** Length in bytes */ + length: number; +} + +/** + * Output from bytecode compilation. + */ +export interface BytecodeOutput { + /** Runtime bytecode */ + runtime: Uint8Array; + /** Creation bytecode (optional) */ + create?: Uint8Array; + /** Disassembled runtime instructions */ + runtimeInstructions: Evm.Instruction[]; + /** Disassembled creation instructions (optional) */ + createInstructions?: Evm.Instruction[]; +} + +/** + * Successful compilation result. + */ +export interface SuccessfulCompileResult { + success: true; + ast: Ast.Program; + ir: Ir.Module; + bytecode: BytecodeOutput; + warnings: string[]; +} + +/** + * Failed compilation result. + */ +export interface FailedCompileResult { + success: false; + error: string; + ast?: Ast.Program; + warnings?: string[]; +} + +/** + * Union of compilation results. + */ +export type CompileResult = SuccessfulCompileResult | FailedCompileResult; + +/** + * Tooltip data for EthdebugTooltip component. + */ +export interface TooltipData { + /** Content to display */ + content: string; + /** X position */ + x: number; + /** Y position */ + y: number; + /** Whether the tooltip is pinned */ + pinned?: boolean; +} + +/** + * State returned by useEthdebugTooltip hook. + */ +export interface TooltipState { + /** Current tooltip data, if any */ + tooltip: TooltipData | null; + /** Show tooltip at position */ + showTooltip: (content: string, x: number, y: number) => void; + /** Hide the tooltip */ + hideTooltip: () => void; + /** Pin the tooltip in place */ + pinTooltip: () => void; + /** Unpin the tooltip */ + unpinTooltip: () => void; + /** Whether the tooltip is currently pinned */ + isPinned: boolean; +} diff --git a/packages/bugc-react/src/utils/bugLanguage.ts b/packages/bugc-react/src/utils/bugLanguage.ts new file mode 100644 index 000000000..90114f379 --- /dev/null +++ b/packages/bugc-react/src/utils/bugLanguage.ts @@ -0,0 +1,240 @@ +/** + * Monaco editor language configuration for the BUG language. + * + * This module provides language definition and syntax highlighting for BUG + * source code in Monaco editor instances. + */ + +/** + * BUG language keywords. + */ +export const keywords = [ + "contract", + "function", + "let", + "if", + "else", + "while", + "return", + "storage", + "memory", + "calldata", + "emit", + "event", + "mapping", + "struct", + "public", + "private", + "internal", + "external", + "view", + "pure", + "payable", + "constant", + "immutable", +]; + +/** + * BUG language type keywords. + */ +export const typeKeywords = [ + "uint256", + "uint128", + "uint64", + "uint32", + "uint16", + "uint8", + "int256", + "int128", + "int64", + "int32", + "int16", + "int8", + "bool", + "address", + "bytes32", + "bytes", + "string", +]; + +/** + * BUG language operators. + */ +export const operators = [ + "=", + ">", + "<", + "!", + "~", + "?", + ":", + "==", + "<=", + ">=", + "!=", + "&&", + "||", + "++", + "--", + "+", + "-", + "*", + "/", + "&", + "|", + "^", + "%", + "<<", + ">>", + "+=", + "-=", + "*=", + "/=", + "&=", + "|=", + "^=", + "%=", + "<<=", + ">>=", +]; + +/** + * Monaco language ID for BUG. + */ +export const languageId = "bug"; + +/** + * Monaco Monarch tokenizer configuration for BUG syntax highlighting. + */ +export const monarchTokensProvider = { + keywords, + typeKeywords, + operators, + + symbols: /[=>](?!@symbols)/, "@brackets"], + [ + /@symbols/, + { + cases: { + "@operators": "operator", + "@default": "", + }, + }, + ], + + // Numbers + [/\d*\.\d+([eE][-+]?\d+)?/, "number.float"], + [/0[xX][0-9a-fA-F]+/, "number.hex"], + [/\d+/, "number"], + + // Delimiter: after number because of .\d floats + [/[;,.]/, "delimiter"], + + // Strings + [/"([^"\\]|\\.)*$/, "string.invalid"], + [/"/, { token: "string.quote", bracket: "@open", next: "@string" }], + + // Characters + [/'[^\\']'/, "string"], + [/(')(@escapes)(')/, ["string", "string.escape", "string"]], + [/'/, "string.invalid"], + ], + + comment: [ + [/[^/*]+/, "comment"], + [/\/\*/, "comment", "@push"], + ["\\*/", "comment", "@pop"], + [/[/*]/, "comment"], + ], + + string: [ + [/[^\\"]+/, "string"], + [/@escapes/, "string.escape"], + [/\\./, "string.escape.invalid"], + [/"/, { token: "string.quote", bracket: "@close", next: "@pop" }], + ], + + whitespace: [ + [/[ \t\r\n]+/, "white"], + [/\/\*/, "comment", "@comment"], + [/\/\/.*$/, "comment"], + ], + }, +}; + +/** + * Monaco language configuration for BUG. + */ +export const languageConfiguration = { + comments: { + lineComment: "//", + blockComment: ["/*", "*/"] as [string, string], + }, + brackets: [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ] as [string, string][], + autoClosingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + surroundingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], +}; + +/** + * Register the BUG language with a Monaco editor instance. + * + * @param monaco - The Monaco editor module + */ +export function registerBugLanguage( + monaco: typeof import("monaco-editor"), +): void { + // Register the language + monaco.languages.register({ id: languageId }); + + // Set the language configuration + monaco.languages.setLanguageConfiguration( + languageId, + languageConfiguration as import("monaco-editor").languages.LanguageConfiguration, + ); + + // Set the tokenizer + monaco.languages.setMonarchTokensProvider( + languageId, + monarchTokensProvider as import("monaco-editor").languages.IMonarchLanguage, + ); +} diff --git a/packages/bugc-react/src/utils/debugUtils.ts b/packages/bugc-react/src/utils/debugUtils.ts new file mode 100644 index 000000000..bf7485558 --- /dev/null +++ b/packages/bugc-react/src/utils/debugUtils.ts @@ -0,0 +1,95 @@ +/** + * Utilities for extracting debug information from ethdebug format contexts. + */ + +import type { SourceRange } from "#types"; + +/** + * Extract source ranges from an ethdebug format context object. + * + * Handles various context types including: + * - code: Direct source range + * - gather: Multiple code regions + * - pick: Conditional selection + * - frame: Nested context + * + * @param context - The ethdebug context object + * @returns Array of source ranges + */ +export function extractSourceRange( + context: unknown | undefined, +): SourceRange[] { + if (!context || typeof context !== "object") { + return []; + } + + const ctx = context as Record; + + // Handle "code" context - direct source mapping + if (ctx.code && typeof ctx.code === "object") { + const code = ctx.code as Record; + if (code.range && typeof code.range === "object") { + const range = code.range as Record; + if ( + typeof range.offset === "number" && + typeof range.length === "number" + ) { + return [{ offset: range.offset, length: range.length }]; + } + } + } + + // Handle "gather" context - multiple code regions + if (ctx.gather && Array.isArray(ctx.gather)) { + const ranges: SourceRange[] = []; + for (const item of ctx.gather) { + ranges.push(...extractSourceRange(item)); + } + return ranges; + } + + // Handle "pick" context - select one from options + if (ctx.pick && Array.isArray(ctx.pick)) { + // For now, just return all possible ranges + const ranges: SourceRange[] = []; + for (const item of ctx.pick) { + ranges.push(...extractSourceRange(item)); + } + return ranges; + } + + // Handle "frame" context - nested context + if (ctx.frame && typeof ctx.frame === "object") { + return extractSourceRange(ctx.frame); + } + + // Try to extract from nested context property + if (ctx.context) { + return extractSourceRange(ctx.context); + } + + return []; +} + +/** + * Format an ethdebug context object as a readable JSON string. + * + * @param context - The context object to format + * @returns Formatted JSON string + */ +export function formatDebugContext(context: unknown): string { + if (!context) { + return ""; + } + return JSON.stringify(context, null, 2); +} + +/** + * Check if a context contains source range information. + * + * @param context - The context to check + * @returns True if the context contains source ranges + */ +export function hasSourceRange(context: unknown): boolean { + return extractSourceRange(context).length > 0; +} diff --git a/packages/bugc-react/src/utils/formatBytecode.ts b/packages/bugc-react/src/utils/formatBytecode.ts new file mode 100644 index 000000000..c955d9765 --- /dev/null +++ b/packages/bugc-react/src/utils/formatBytecode.ts @@ -0,0 +1,193 @@ +/** + * Bytecode formatting and disassembly utilities. + */ + +/** + * EVM opcode names by byte value. + */ +export const OPCODES: Record = { + 0x00: "STOP", + 0x01: "ADD", + 0x02: "MUL", + 0x03: "SUB", + 0x04: "DIV", + 0x05: "SDIV", + 0x06: "MOD", + 0x07: "SMOD", + 0x08: "ADDMOD", + 0x09: "MULMOD", + 0x0a: "EXP", + 0x0b: "SIGNEXTEND", + 0x10: "LT", + 0x11: "GT", + 0x12: "SLT", + 0x13: "SGT", + 0x14: "EQ", + 0x15: "ISZERO", + 0x16: "AND", + 0x17: "OR", + 0x18: "XOR", + 0x19: "NOT", + 0x1a: "BYTE", + 0x1b: "SHL", + 0x1c: "SHR", + 0x1d: "SAR", + 0x20: "KECCAK256", + 0x30: "ADDRESS", + 0x31: "BALANCE", + 0x32: "ORIGIN", + 0x33: "CALLER", + 0x34: "CALLVALUE", + 0x35: "CALLDATALOAD", + 0x36: "CALLDATASIZE", + 0x37: "CALLDATACOPY", + 0x38: "CODESIZE", + 0x39: "CODECOPY", + 0x3a: "GASPRICE", + 0x3b: "EXTCODESIZE", + 0x3c: "EXTCODECOPY", + 0x3d: "RETURNDATASIZE", + 0x3e: "RETURNDATACOPY", + 0x3f: "EXTCODEHASH", + 0x40: "BLOCKHASH", + 0x41: "COINBASE", + 0x42: "TIMESTAMP", + 0x43: "NUMBER", + 0x44: "DIFFICULTY", + 0x45: "GASLIMIT", + 0x46: "CHAINID", + 0x47: "SELFBALANCE", + 0x48: "BASEFEE", + 0x50: "POP", + 0x51: "MLOAD", + 0x52: "MSTORE", + 0x53: "MSTORE8", + 0x54: "SLOAD", + 0x55: "SSTORE", + 0x56: "JUMP", + 0x57: "JUMPI", + 0x58: "PC", + 0x59: "MSIZE", + 0x5a: "GAS", + 0x5b: "JUMPDEST", + 0x5f: "PUSH0", + 0x60: "PUSH1", + 0x61: "PUSH2", + 0x62: "PUSH3", + 0x63: "PUSH4", + 0x64: "PUSH5", + 0x65: "PUSH6", + 0x66: "PUSH7", + 0x67: "PUSH8", + 0x68: "PUSH9", + 0x69: "PUSH10", + 0x6a: "PUSH11", + 0x6b: "PUSH12", + 0x6c: "PUSH13", + 0x6d: "PUSH14", + 0x6e: "PUSH15", + 0x6f: "PUSH16", + 0x70: "PUSH17", + 0x71: "PUSH18", + 0x72: "PUSH19", + 0x73: "PUSH20", + 0x74: "PUSH21", + 0x75: "PUSH22", + 0x76: "PUSH23", + 0x77: "PUSH24", + 0x78: "PUSH25", + 0x79: "PUSH26", + 0x7a: "PUSH27", + 0x7b: "PUSH28", + 0x7c: "PUSH29", + 0x7d: "PUSH30", + 0x7e: "PUSH31", + 0x7f: "PUSH32", + 0x80: "DUP1", + 0x81: "DUP2", + 0x82: "DUP3", + 0x83: "DUP4", + 0x84: "DUP5", + 0x85: "DUP6", + 0x86: "DUP7", + 0x87: "DUP8", + 0x88: "DUP9", + 0x89: "DUP10", + 0x8a: "DUP11", + 0x8b: "DUP12", + 0x8c: "DUP13", + 0x8d: "DUP14", + 0x8e: "DUP15", + 0x8f: "DUP16", + 0x90: "SWAP1", + 0x91: "SWAP2", + 0x92: "SWAP3", + 0x93: "SWAP4", + 0x94: "SWAP5", + 0x95: "SWAP6", + 0x96: "SWAP7", + 0x97: "SWAP8", + 0x98: "SWAP9", + 0x99: "SWAP10", + 0x9a: "SWAP11", + 0x9b: "SWAP12", + 0x9c: "SWAP13", + 0x9d: "SWAP14", + 0x9e: "SWAP15", + 0x9f: "SWAP16", + 0xa0: "LOG0", + 0xa1: "LOG1", + 0xa2: "LOG2", + 0xa3: "LOG3", + 0xa4: "LOG4", + 0xf0: "CREATE", + 0xf1: "CALL", + 0xf2: "CALLCODE", + 0xf3: "RETURN", + 0xf4: "DELEGATECALL", + 0xf5: "CREATE2", + 0xfa: "STATICCALL", + 0xfd: "REVERT", + 0xfe: "INVALID", + 0xff: "SELFDESTRUCT", +}; + +/** + * Format bytecode hex string as disassembled instructions. + * + * @param hex - Bytecode as hex string (without 0x prefix) + * @returns Formatted disassembly with addresses and mnemonics + */ +export function formatBytecode(hex: string): string { + const lines: string[] = []; + let offset = 0; + + while (offset < hex.length) { + const pc = offset / 2; + const opcode = parseInt(hex.substr(offset, 2), 16); + const opName = OPCODES[opcode] || `UNKNOWN(0x${hex.substr(offset, 2)})`; + + // Handle PUSH instructions + if (opcode >= 0x60 && opcode <= 0x7f) { + const pushSize = opcode - 0x5f; + const value = hex.substr(offset + 2, pushSize * 2); + lines.push(`${pc.toString().padStart(4, "0")} ${opName} 0x${value}`); + offset += 2 + pushSize * 2; + } else { + lines.push(`${pc.toString().padStart(4, "0")} ${opName}`); + offset += 2; + } + } + + return lines.join("\n"); +} + +/** + * Get the opcode name for a given byte value. + * + * @param opcode - Opcode byte value + * @returns Opcode mnemonic or "UNKNOWN" + */ +export function getOpcodeName(opcode: number): string { + return OPCODES[opcode] || "UNKNOWN"; +} diff --git a/packages/bugc-react/src/utils/index.ts b/packages/bugc-react/src/utils/index.ts new file mode 100644 index 000000000..335e2e091 --- /dev/null +++ b/packages/bugc-react/src/utils/index.ts @@ -0,0 +1,31 @@ +/** + * Utility exports for @ethdebug/bugc-react. + */ + +export { + extractSourceRange, + formatDebugContext, + hasSourceRange, +} from "./debugUtils.js"; + +export { + extractInstructionDebug, + extractTerminatorDebug, + extractPhiDebug, + formatMultiLevelDebug, + extractAllSourceRanges, + extractOperandSourceRanges, + type MultiLevelDebugInfo, +} from "./irDebugUtils.js"; + +export { formatBytecode, getOpcodeName, OPCODES } from "./formatBytecode.js"; + +export { + keywords as bugKeywords, + typeKeywords as bugTypeKeywords, + operators as bugOperators, + languageId as bugLanguageId, + monarchTokensProvider as bugMonarchTokensProvider, + languageConfiguration as bugLanguageConfiguration, + registerBugLanguage, +} from "./bugLanguage.js"; diff --git a/packages/bugc-react/src/utils/irDebugUtils.ts b/packages/bugc-react/src/utils/irDebugUtils.ts new file mode 100644 index 000000000..11bdcc767 --- /dev/null +++ b/packages/bugc-react/src/utils/irDebugUtils.ts @@ -0,0 +1,245 @@ +/** + * Utilities for extracting debug information from IR instructions. + */ + +import type { Ir } from "@ethdebug/bugc"; +import type { SourceRange } from "#types"; +import { extractSourceRange } from "./debugUtils.js"; + +/** + * Multi-level debug information extracted from IR elements. + */ +export interface MultiLevelDebugInfo { + /** Debug info for the operation itself */ + operation?: Ir.Instruction.Debug | Ir.Block.Debug; + /** Debug info for each operand */ + operands: { label: string; debug?: Ir.Instruction.Debug | Ir.Block.Debug }[]; +} + +/** + * Extract all debug contexts from an IR instruction. + * + * @param instruction - The IR instruction + * @returns Multi-level debug info with operation and operand contexts + */ +export function extractInstructionDebug( + instruction: Ir.Instruction, +): MultiLevelDebugInfo { + const operands: { + label: string; + debug?: Ir.Instruction.Debug | Ir.Block.Debug; + }[] = []; + + switch (instruction.kind) { + case "read": + if (instruction.slot) { + operands.push({ label: "slot", debug: instruction.slotDebug }); + } + if (instruction.offset) { + operands.push({ label: "offset", debug: instruction.offsetDebug }); + } + if (instruction.length) { + operands.push({ label: "length", debug: instruction.lengthDebug }); + } + break; + + case "write": + if (instruction.slot) { + operands.push({ label: "slot", debug: instruction.slotDebug }); + } + if (instruction.offset) { + operands.push({ label: "offset", debug: instruction.offsetDebug }); + } + if (instruction.length) { + operands.push({ label: "length", debug: instruction.lengthDebug }); + } + operands.push({ label: "value", debug: instruction.valueDebug }); + break; + + case "compute_offset": + operands.push({ label: "base", debug: instruction.baseDebug }); + if ("index" in instruction && instruction.index) { + operands.push({ label: "index", debug: instruction.indexDebug }); + } + if ("offset" in instruction && instruction.offset) { + operands.push({ label: "offset", debug: instruction.offsetDebug }); + } + break; + + case "compute_slot": + operands.push({ label: "base", debug: instruction.baseDebug }); + if ("key" in instruction && instruction.key) { + operands.push({ label: "key", debug: instruction.keyDebug }); + } + break; + + case "const": + operands.push({ label: "value", debug: instruction.valueDebug }); + break; + + case "allocate": + operands.push({ label: "size", debug: instruction.sizeDebug }); + break; + + case "binary": + operands.push({ label: "left", debug: instruction.leftDebug }); + operands.push({ label: "right", debug: instruction.rightDebug }); + break; + + case "unary": + operands.push({ label: "operand", debug: instruction.operandDebug }); + break; + + case "hash": + operands.push({ label: "value", debug: instruction.valueDebug }); + break; + + case "cast": + operands.push({ label: "value", debug: instruction.valueDebug }); + break; + + case "length": + operands.push({ label: "object", debug: instruction.objectDebug }); + break; + } + + return { + operation: instruction.operationDebug, + operands, + }; +} + +/** + * Extract all debug contexts from a terminator. + * + * @param terminator - The block terminator + * @returns Multi-level debug info + */ +export function extractTerminatorDebug( + terminator: Ir.Block.Terminator, +): MultiLevelDebugInfo { + const operands: { + label: string; + debug?: Ir.Instruction.Debug | Ir.Block.Debug; + }[] = []; + + switch (terminator.kind) { + case "branch": + operands.push({ label: "condition", debug: terminator.conditionDebug }); + break; + + case "return": + if (terminator.value) { + operands.push({ label: "value", debug: terminator.valueDebug }); + } + break; + + case "call": + if (terminator.argumentsDebug) { + terminator.arguments.forEach((_, index) => { + operands.push({ + label: `arg[${index}]`, + debug: terminator.argumentsDebug?.[index], + }); + }); + } + break; + } + + return { + operation: terminator.operationDebug, + operands, + }; +} + +/** + * Extract debug contexts from a phi node. + * + * @param phi - The phi instruction + * @returns Multi-level debug info + */ +export function extractPhiDebug(phi: Ir.Block.Phi): MultiLevelDebugInfo { + const operands: { + label: string; + debug?: Ir.Instruction.Debug | Ir.Block.Debug; + }[] = []; + + if (phi.sourcesDebug) { + for (const pred of phi.sources.keys()) { + const debug = phi.sourcesDebug.get(pred); + operands.push({ label: `from ${pred}`, debug }); + } + } + + return { + operation: phi.operationDebug, + operands, + }; +} + +/** + * Format multi-level debug info as hierarchical JSON. + * + * @param info - The debug info to format + * @returns Formatted JSON string + */ +export function formatMultiLevelDebug(info: MultiLevelDebugInfo): string { + const result: Record = {}; + + if (info.operation?.context) { + result.operation = info.operation.context; + } + + const operandsWithDebug = info.operands.filter((op) => op.debug?.context); + if (operandsWithDebug.length > 0) { + result.operands = Object.fromEntries( + operandsWithDebug.map((op) => [op.label, op.debug!.context]), + ); + } + + return JSON.stringify(result, null, 2); +} + +/** + * Extract all source ranges from multi-level debug info. + * + * @param info - The debug info + * @returns Array of source ranges from all levels + */ +export function extractAllSourceRanges( + info: MultiLevelDebugInfo, +): SourceRange[] { + const ranges: SourceRange[] = []; + + // Operation debug + if (info.operation?.context) { + ranges.push(...extractSourceRange(info.operation.context)); + } + + // Operand debug + for (const operand of info.operands) { + if (operand.debug?.context) { + ranges.push(...extractSourceRange(operand.debug.context)); + } + } + + return ranges; +} + +/** + * Extract source ranges from a specific operand by label. + * + * @param info - The debug info + * @param label - The operand label to look for + * @returns Source ranges for the specified operand + */ +export function extractOperandSourceRanges( + info: MultiLevelDebugInfo, + label: string, +): SourceRange[] { + const operand = info.operands.find((op) => op.label === label); + if (!operand?.debug?.context) { + return []; + } + return extractSourceRange(operand.debug.context); +} diff --git a/packages/bugc-react/tsconfig.json b/packages/bugc-react/tsconfig.json new file mode 100644 index 000000000..7dd5776e5 --- /dev/null +++ b/packages/bugc-react/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "./dist/", + "baseUrl": "./", + "jsx": "react-jsx", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "paths": { + "#components/*": ["./src/components/*"], + "#hooks/*": ["./src/hooks/*"], + "#utils/*": ["./src/utils/*"], + "#types": ["./src/types"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../bugc" }] +} diff --git a/packages/bugc/package.json b/packages/bugc/package.json index 39be792dd..d8aa9cfdc 100644 --- a/packages/bugc/package.json +++ b/packages/bugc/package.json @@ -85,8 +85,6 @@ "@hyperjump/json-schema": "^1.11.0", "@types/node": "^20.0.0", "@types/parsimmon": "^1.10.9", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", "@vitest/coverage-v8": "^2.1.8", "@vitest/ui": "^2.1.8", "eslint": "^8.0.0", diff --git a/packages/evm/package.json b/packages/evm/package.json new file mode 100644 index 000000000..02c5c8336 --- /dev/null +++ b/packages/evm/package.json @@ -0,0 +1,53 @@ +{ + "name": "@ethdebug/evm", + "version": "0.1.0-0", + "description": "EVM execution and state access for ethdebug/format", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "license": "MIT", + "imports": { + "#executor": { + "types": "./src/executor.ts", + "default": "./dist/src/executor.js" + }, + "#machine": { + "types": "./src/machine.ts", + "default": "./dist/src/machine.js" + }, + "#trace": { + "types": "./src/trace.ts", + "default": "./dist/src/trace.js" + } + }, + "scripts": { + "prepare": "tsc", + "build": "tsc", + "watch": "tsc --watch --preserveWatchOutput", + "test": "vitest run" + }, + "dependencies": { + "@ethereumjs/common": "^10.0.0", + "@ethereumjs/evm": "^10.0.0", + "@ethereumjs/statemanager": "^10.0.0", + "@ethereumjs/util": "^10.0.0", + "ethereum-cryptography": "^3.2.0" + }, + "devDependencies": { + "@ethdebug/pointers": "^0.1.0-0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "@ethdebug/pointers": "^0.1.0-0" + }, + "peerDependenciesMeta": { + "@ethdebug/pointers": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/evm/src/executor.ts b/packages/evm/src/executor.ts new file mode 100644 index 000000000..17a293075 --- /dev/null +++ b/packages/evm/src/executor.ts @@ -0,0 +1,304 @@ +/** + * EVM Executor + * + * Provides in-process EVM execution using @ethereumjs/evm. + * Supports contract deployment, execution, and storage access. + */ + +import { EVM } from "@ethereumjs/evm"; +import { SimpleStateManager } from "@ethereumjs/statemanager"; +import { Common, Mainnet } from "@ethereumjs/common"; +import { Address, Account } from "@ethereumjs/util"; +import { hexToBytes, bytesToHex } from "ethereum-cryptography/utils"; + +import type { TraceStep, TraceHandler } from "#trace"; + +/** + * Options for executing a contract call. + */ +export interface ExecutionOptions { + /** ETH value to send with the call */ + value?: bigint; + /** Calldata as hex string (without 0x prefix) */ + data?: string; + /** Transaction origin address */ + origin?: Address; + /** Caller address */ + caller?: Address; + /** Gas limit for execution */ + gasLimit?: bigint; +} + +/** + * Result of contract execution. + */ +export interface ExecutionResult { + /** Whether execution completed without error */ + success: boolean; + /** Gas consumed by execution */ + gasUsed: bigint; + /** Return data from the call */ + returnValue: Uint8Array; + /** Event logs emitted during execution */ + logs: unknown[]; + /** Error if execution failed */ + error?: unknown; +} + +interface ExecResult { + exceptionError?: unknown; + executionGasUsed?: bigint; + returnValue?: Uint8Array; + logs?: unknown[]; +} + +interface ResultWithExec extends ExecResult { + execResult?: ExecResult; +} + +/** + * EVM executor for running bytecode in an isolated environment. + * + * Wraps @ethereumjs/evm to provide a simple interface for: + * - Deploying contracts + * - Executing contract calls + * - Reading/writing storage + * - Capturing execution traces + */ +export class Executor { + private evm: EVM; + private stateManager: SimpleStateManager; + private contractAddress: Address; + private deployerAddress: Address; + + constructor() { + const common = new Common({ + chain: Mainnet, + hardfork: "shanghai", + }); + this.stateManager = new SimpleStateManager(); + this.evm = new EVM({ + common, + stateManager: this.stateManager, + }); + + // Use a fixed contract address for testing + this.contractAddress = new Address( + hexToBytes("1234567890123456789012345678901234567890"), + ); + + // Use a fixed deployer address + this.deployerAddress = new Address( + hexToBytes("0000000000000000000000000000000000000001"), + ); + } + + /** + * Get the deployer address used for deployment. + */ + getDeployerAddress(): Address { + return this.deployerAddress; + } + + /** + * Get the current contract address. + */ + getContractAddress(): Address { + return this.contractAddress; + } + + /** + * Deploy bytecode and set the contract address. + * + * @param bytecode - Contract creation bytecode as hex string + */ + async deploy(bytecode: string): Promise { + const code = hexToBytes(bytecode); + + // Initialize deployer account with 1 ETH + const deployerAccount = new Account(0n, BigInt(10) ** BigInt(18)); + await this.stateManager.putAccount(this.deployerAddress, deployerAccount); + + // Initialize contract account before execution + const contractAccount = new Account(0n, 0n); + await this.stateManager.putAccount(this.contractAddress, contractAccount); + + // Use runCall with undefined 'to' to simulate CREATE + const result = await this.evm.runCall({ + caller: this.deployerAddress, + origin: this.deployerAddress, + to: undefined, + data: code, + gasLimit: 10_000_000n, + value: 0n, + }); + + const error = result.execResult?.exceptionError; + + if (error) { + throw new Error(`Deployment failed: ${JSON.stringify(error)}`); + } + + // Update contract address to the created one + const createdAddress = result.createdAddress; + if (createdAddress) { + this.contractAddress = createdAddress; + } + } + + /** + * Execute a call to the deployed contract. + * + * @param options - Execution options (value, data, gas, etc.) + * @param traceHandler - Optional handler for execution trace steps + */ + async execute( + options: ExecutionOptions = {}, + traceHandler?: TraceHandler, + ): Promise { + const runCallOpts = { + to: this.contractAddress, + caller: options.caller ?? this.deployerAddress, + origin: options.origin ?? this.deployerAddress, + data: options.data ? hexToBytes(options.data) : new Uint8Array(), + value: options.value ?? 0n, + gasLimit: options.gasLimit ?? 10_000_000n, + }; + + if (traceHandler) { + this.evm.events.on( + "step", + (step: { pc: number; opcode: { name: string }; stack: bigint[] }) => { + const traceStep: TraceStep = { + pc: step.pc, + opcode: step.opcode.name, + stack: [...step.stack], + }; + traceHandler(traceStep); + }, + ); + } + + const result = await this.evm.runCall(runCallOpts); + + if (traceHandler) { + this.evm.events.removeAllListeners("step"); + } + + const rawResult = result as ResultWithExec; + const execResult = (rawResult.execResult || rawResult) as ExecResult; + + return { + success: execResult.exceptionError === undefined, + gasUsed: execResult.executionGasUsed || 0n, + returnValue: execResult.returnValue || new Uint8Array(), + logs: execResult.logs || [], + error: execResult.exceptionError, + }; + } + + /** + * Execute bytecode directly without deployment. + * + * @param bytecode - Bytecode to execute as hex string + * @param options - Execution options + */ + async executeCode( + bytecode: string, + options: ExecutionOptions = {}, + ): Promise { + const code = hexToBytes(bytecode); + + // Create a temporary account with the code + const tempAddress = new Address( + hexToBytes("9999999999999999999999999999999999999999"), + ); + await this.stateManager.putCode(tempAddress, code); + await this.stateManager.putAccount(tempAddress, new Account(0n, 0n)); + + const runCodeOpts = { + code, + data: options.data ? hexToBytes(options.data) : new Uint8Array(), + gasLimit: options.gasLimit ?? 10_000_000n, + value: options.value ?? 0n, + origin: options.origin ?? new Address(Buffer.alloc(20)), + caller: options.caller ?? new Address(Buffer.alloc(20)), + address: tempAddress, + }; + + const result = await this.evm.runCode(runCodeOpts); + + const rawResult = result as ResultWithExec; + const execResult = (rawResult.execResult || rawResult) as ExecResult; + + return { + success: execResult.exceptionError === undefined, + gasUsed: execResult.executionGasUsed || 0n, + returnValue: execResult.returnValue || new Uint8Array(), + logs: execResult.logs || [], + error: execResult.exceptionError, + }; + } + + /** + * Get storage value at a specific slot. + * + * @param slot - Storage slot as bigint + * @returns Storage value as bigint + */ + async getStorage(slot: bigint): Promise { + const slotBuffer = Buffer.alloc(32); + const hex = slot.toString(16).padStart(64, "0"); + slotBuffer.write(hex, "hex"); + + const value = await this.stateManager.getStorage( + this.contractAddress, + slotBuffer, + ); + + if (value.length === 0) return 0n; + return BigInt("0x" + bytesToHex(value)); + } + + /** + * Set storage value at a specific slot. + * + * @param slot - Storage slot as bigint + * @param value - Value to store as bigint + */ + async setStorage(slot: bigint, value: bigint): Promise { + const slotBuffer = Buffer.alloc(32); + slotBuffer.writeBigUInt64BE(slot, 24); + + const valueBuffer = Buffer.alloc(32); + const hex = value.toString(16).padStart(64, "0"); + valueBuffer.write(hex, "hex"); + + await this.stateManager.putStorage( + this.contractAddress, + slotBuffer, + valueBuffer, + ); + } + + /** + * Get the deployed bytecode at the contract address. + */ + async getCode(): Promise { + return this.stateManager.getCode(this.contractAddress); + } + + /** + * Reset the EVM state to a fresh instance. + */ + async reset(): Promise { + this.stateManager = new SimpleStateManager(); + this.evm = new EVM({ + common: new Common({ + chain: Mainnet, + hardfork: "shanghai", + }), + stateManager: this.stateManager, + }); + } +} diff --git a/packages/evm/src/index.ts b/packages/evm/src/index.ts new file mode 100644 index 000000000..1784e5521 --- /dev/null +++ b/packages/evm/src/index.ts @@ -0,0 +1,37 @@ +/** + * @ethdebug/evm + * + * EVM execution and state access for ethdebug/format. + * + * This package provides: + * - An EVM executor for running bytecode in isolation + * - A Machine.State adapter for pointer evaluation + * - Execution trace capture utilities + * + * @example + * ```typescript + * import { Executor, createMachineState } from "@ethdebug/evm"; + * import { dereference } from "@ethdebug/pointers"; + * + * // Create executor and deploy contract + * const executor = new Executor(); + * await executor.deploy(bytecode); + * await executor.execute(); + * + * // Create machine state for pointer evaluation + * const state = createMachineState(executor); + * const cursor = await dereference(pointer, { state }); + * ``` + */ + +// Executor +export { Executor } from "#executor"; +export type { ExecutionOptions, ExecutionResult } from "#executor"; + +// Machine state adapter +export { createMachineState } from "#machine"; +export type { MachineStateOptions } from "#machine"; + +// Trace types +export { createTraceCollector } from "#trace"; +export type { TraceStep, TraceHandler, Trace } from "#trace"; diff --git a/packages/evm/src/machine.ts b/packages/evm/src/machine.ts new file mode 100644 index 000000000..b96aa99ad --- /dev/null +++ b/packages/evm/src/machine.ts @@ -0,0 +1,105 @@ +/** + * Machine.State Adapter + * + * Implements the @ethdebug/pointers Machine.State interface + * to enable pointer evaluation against EVM executor state. + */ + +import type { Machine } from "@ethdebug/pointers"; +import { Data } from "@ethdebug/pointers"; +import type { Executor } from "#executor"; + +/** + * Options for creating a Machine.State adapter. + */ +export interface MachineStateOptions { + /** Program counter for the current state */ + programCounter?: bigint; + /** Opcode at the current program counter */ + opcode?: string; + /** Trace index (step number) */ + traceIndex?: bigint; +} + +/** + * Create a Machine.State from an Executor. + * + * This adapter allows using @ethdebug/pointers dereference() + * to evaluate pointers against an EVM executor's storage state. + * + * Note: This creates an "end-state" adapter where only storage + * is fully implemented. Stack, memory, etc. return empty/zero + * values since we only have post-execution state access. + * + * @param executor - The EVM executor to read state from + * @param options - Optional state context (PC, opcode, trace index) + * @returns A Machine.State suitable for pointer dereferencing + */ +export function createMachineState( + executor: Executor, + options: MachineStateOptions = {}, +): Machine.State { + const { programCounter = 0n, opcode = "STOP", traceIndex = 0n } = options; + + return { + // Trace context + traceIndex: Promise.resolve(traceIndex), + programCounter: Promise.resolve(programCounter), + opcode: Promise.resolve(opcode), + + // Stack - not available in end-state + stack: { + length: Promise.resolve(0n), + peek: async (): Promise => Data.zero(), + }, + + // Memory - not available in end-state + memory: { + length: Promise.resolve(0n), + read: async (): Promise => Data.zero(), + }, + + // Storage - fully implemented via executor + storage: { + async read({ slot, slice }): Promise { + const slotValue = slot.asUint(); + const value = await executor.getStorage(slotValue); + const data = Data.fromUint(value); + + if (slice) { + const padded = data.padUntilAtLeast(32); + const sliced = new Uint8Array(padded).slice( + Number(slice.offset), + Number(slice.offset + slice.length), + ); + return Data.fromBytes(sliced); + } + + return data.padUntilAtLeast(32); + }, + }, + + // Calldata - not available in end-state + calldata: { + length: Promise.resolve(0n), + read: async (): Promise => Data.zero(), + }, + + // Returndata - not available in end-state + returndata: { + length: Promise.resolve(0n), + read: async (): Promise => Data.zero(), + }, + + // Code - not available in end-state + code: { + length: Promise.resolve(0n), + read: async (): Promise => Data.zero(), + }, + + // Transient storage - not available in end-state + transient: { + read: async (): Promise => Data.zero(), + }, + }; +} diff --git a/packages/evm/src/trace.ts b/packages/evm/src/trace.ts new file mode 100644 index 000000000..2f9e3e389 --- /dev/null +++ b/packages/evm/src/trace.ts @@ -0,0 +1,52 @@ +/** + * EVM Execution Trace Types + * + * Types for capturing and representing EVM execution traces. + */ + +/** + * A single step in an execution trace. + */ +export interface TraceStep { + /** Program counter */ + pc: number; + /** Opcode name (e.g., "PUSH1", "SLOAD") */ + opcode: string; + /** Stack state at this step */ + stack: bigint[]; + /** Memory state at this step (optional) */ + memory?: Uint8Array; + /** Gas remaining (optional) */ + gasRemaining?: bigint; +} + +/** + * Handler function for trace steps during execution. + */ +export type TraceHandler = (step: TraceStep) => void; + +/** + * A complete execution trace. + */ +export interface Trace { + /** All steps in the trace */ + steps: TraceStep[]; +} + +/** + * Create a trace handler that collects steps into a Trace object. + * + * @returns A tuple of [handler, getTrace] where handler collects steps + * and getTrace returns the collected trace. + */ +export function createTraceCollector(): [TraceHandler, () => Trace] { + const steps: TraceStep[] = []; + + const handler: TraceHandler = (step) => { + steps.push(step); + }; + + const getTrace = (): Trace => ({ steps: [...steps] }); + + return [handler, getTrace]; +} diff --git a/packages/evm/tsconfig.json b/packages/evm/tsconfig.json new file mode 100644 index 000000000..18545ef8c --- /dev/null +++ b/packages/evm/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "./dist/", + "baseUrl": "./", + "paths": { + "#executor": ["./src/executor"], + "#machine": ["./src/machine"], + "#trace": ["./src/trace"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../pointers" }] +} diff --git a/packages/evm/vitest.config.ts b/packages/evm/vitest.config.ts new file mode 100644 index 000000000..c88a255c3 --- /dev/null +++ b/packages/evm/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules/", "dist/", "**/*.test.ts"], + }, + }, +}); diff --git a/packages/playground/.eslintrc.cjs b/packages/playground/.eslintrc.cjs deleted file mode 100644 index 62b6946be..000000000 --- a/packages/playground/.eslintrc.cjs +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - ], - ignorePatterns: ["dist", ".eslintrc.cjs"], - parser: "@typescript-eslint/parser", - plugins: ["react-refresh"], - rules: { - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "no-console": "off", - }, -}; diff --git a/packages/playground/package.json b/packages/playground/package.json index ffaf5f884..5eef47706 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -27,8 +27,6 @@ "devDependencies": { "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", diff --git a/packages/pointers-react/package.json b/packages/pointers-react/package.json new file mode 100644 index 000000000..86d5ba896 --- /dev/null +++ b/packages/pointers-react/package.json @@ -0,0 +1,51 @@ +{ + "name": "@ethdebug/pointers-react", + "version": "0.1.0-0", + "description": "React components for visualizing ethdebug/format pointer resolution", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "license": "MIT", + "imports": { + "#components/*": { + "types": "./src/components/*.tsx", + "default": "./dist/src/components/*.js" + }, + "#hooks/*": { + "types": "./src/hooks/*.ts", + "default": "./dist/src/hooks/*.js" + }, + "#utils/*": { + "types": "./src/utils/*.ts", + "default": "./dist/src/utils/*.js" + } + }, + "scripts": { + "prepare": "tsc", + "build": "tsc", + "watch": "tsc --watch --preserveWatchOutput", + "test": "vitest run" + }, + "dependencies": { + "@ethdebug/format": "^0.1.0-0", + "@ethdebug/pointers": "^0.1.0-0" + }, + "devDependencies": { + "@testing-library/dom": "^10.0.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "jsdom": "^26.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/pointers-react/src/components/PointerResolverContext.tsx b/packages/pointers-react/src/components/PointerResolverContext.tsx new file mode 100644 index 000000000..9da54840e --- /dev/null +++ b/packages/pointers-react/src/components/PointerResolverContext.tsx @@ -0,0 +1,63 @@ +/** + * React context for pointer resolution state management. + */ + +import React, { createContext, useContext } from "react"; +import type { Pointer } from "@ethdebug/format"; +import { + usePointerResolution, + type PointerResolutionState, + type UsePointerResolutionOptions, +} from "#hooks/usePointerResolution"; +import type { MockStateSpec } from "#utils/mockState"; + +const PointerResolverContext = createContext< + PointerResolutionState | undefined +>(undefined); + +/** + * Hook to access the PointerResolver context. + * + * @returns The current pointer resolution state + * @throws If used outside of a PointerResolverProvider + */ +export function usePointerResolverContext(): PointerResolutionState { + const context = useContext(PointerResolverContext); + if (context === undefined) { + throw new Error( + "usePointerResolverContext must be used within a PointerResolverProvider", + ); + } + return context; +} + +/** + * Props for PointerResolverProvider. + */ +export interface PointerResolverProviderProps extends UsePointerResolutionOptions { + children: React.ReactNode; +} + +/** + * Provides pointer resolution context to child components. + * + * @param props - Provider configuration and children + * @returns Context provider wrapping children + */ +export function PointerResolverProvider({ + children, + ...options +}: PointerResolverProviderProps): JSX.Element { + const state = usePointerResolution(options); + + return ( + + {children} + + ); +} + +/** + * Re-export types for convenience. + */ +export type { Pointer, MockStateSpec, PointerResolutionState }; diff --git a/packages/pointers-react/src/components/RegionMap.css b/packages/pointers-react/src/components/RegionMap.css new file mode 100644 index 000000000..0a68a7982 --- /dev/null +++ b/packages/pointers-react/src/components/RegionMap.css @@ -0,0 +1,117 @@ +/** + * Styles for RegionMap component. + */ + +.region-map { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.region-map-empty { + padding: 1.5rem; + text-align: center; +} + +.region-map-empty-text { + font-size: 0.875rem; + color: var(--pointers-text-muted); +} + +.region-map-entry { + border: 1px solid var(--pointers-border-secondary); + border-radius: 6px; + overflow: hidden; +} + +.region-map-entry.selected { + border-color: var(--pointers-border-highlight); +} + +.region-map-name { + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + font-weight: 600; + font-family: var(--ifm-font-family-monospace, monospace); + background: var(--pointers-bg-secondary); + color: var(--pointers-text-primary); +} + +.region-map-regions { + display: flex; + flex-direction: column; +} + +.region-map-region { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--pointers-border-secondary); + cursor: pointer; + transition: background 0.15s; +} + +.region-map-region:hover { + background: var(--pointers-bg-hover); +} + +.region-map-region:focus { + outline: none; + background: var(--pointers-bg-highlight); +} + +.region-location-badge { + display: inline-block; + padding: 0.125rem 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + border-radius: 3px; + white-space: nowrap; +} + +/* Region type colors */ +.region-storage .region-location-badge { + color: var(--pointers-region-storage); + background: var(--pointers-region-storage-bg); +} + +.region-memory .region-location-badge { + color: var(--pointers-region-memory); + background: var(--pointers-region-memory-bg); +} + +.region-stack .region-location-badge { + color: var(--pointers-region-stack); + background: var(--pointers-region-stack-bg); +} + +.region-calldata .region-location-badge { + color: var(--pointers-region-calldata); + background: var(--pointers-region-calldata-bg); +} + +.region-returndata .region-location-badge { + color: var(--pointers-region-returndata); + background: var(--pointers-region-returndata-bg); +} + +.region-code .region-location-badge { + color: var(--pointers-region-code); + background: var(--pointers-region-code-bg); +} + +.region-transient .region-location-badge { + color: var(--pointers-region-transient); + background: var(--pointers-region-transient-bg); +} + +.region-details { + font-size: 0.75rem; + font-family: var(--ifm-font-family-monospace, monospace); + color: var(--pointers-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/pointers-react/src/components/RegionMap.tsx b/packages/pointers-react/src/components/RegionMap.tsx new file mode 100644 index 000000000..a59039493 --- /dev/null +++ b/packages/pointers-react/src/components/RegionMap.tsx @@ -0,0 +1,130 @@ +/** + * Component for displaying a map of named regions. + */ + +import React from "react"; +import type { Cursor } from "@ethdebug/pointers"; +import { formatData } from "#utils/mockState"; + +export interface RegionMapProps { + /** Named regions to display */ + regions: Record; + /** Currently selected region name */ + selectedName?: string; + /** Callback when a region is clicked */ + onRegionClick?: (name: string, region: Cursor.Region) => void; +} + +/** + * Get the location type from a region. + */ +function getLocationType(region: Cursor.Region): string { + if ("location" in region && typeof region.location === "string") { + return region.location; + } + return "unknown"; +} + +/** + * Get CSS class for a location type. + */ +function getLocationClass(location: string): string { + const locationClasses: Record = { + storage: "region-storage", + memory: "region-memory", + stack: "region-stack", + calldata: "region-calldata", + returndata: "region-returndata", + code: "region-code", + transient: "region-transient", + }; + return locationClasses[location] || "region-unknown"; +} + +/** + * Format a region for display. + */ +function formatRegion(region: Cursor.Region): string { + const location = getLocationType(region); + const parts: string[] = [location]; + + if ("slot" in region && region.slot !== undefined) { + const slotHex = formatData(region.slot); + parts.push(`slot: ${slotHex}`); + } + + if ("offset" in region && region.offset !== undefined) { + const offsetHex = formatData(region.offset); + parts.push(`offset: ${offsetHex}`); + } + + if ("length" in region && region.length !== undefined) { + const lengthHex = formatData(region.length); + parts.push(`length: ${lengthHex}`); + } + + return parts.join(", "); +} + +/** + * Component to display a map of named regions. + */ +export function RegionMap({ + regions, + selectedName, + onRegionClick, +}: RegionMapProps): JSX.Element { + const names = Object.keys(regions).sort(); + + if (names.length === 0) { + return ( +
+ No named regions +
+ ); + } + + return ( +
+ {names.map((name) => { + const regionList = regions[name]; + const isSelected = name === selectedName; + + return ( +
+
{name}
+
+ {regionList.map((region, index) => { + const location = getLocationType(region); + const locationClass = getLocationClass(location); + + return ( +
onRegionClick?.(name, region)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + onRegionClick?.(name, region); + } + }} + > + {location} + + {formatRegion(region)} + +
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/packages/pointers-react/src/components/RegionOutput.css b/packages/pointers-react/src/components/RegionOutput.css new file mode 100644 index 000000000..c9112a4b0 --- /dev/null +++ b/packages/pointers-react/src/components/RegionOutput.css @@ -0,0 +1,149 @@ +/** + * Styles for RegionOutput component. + */ + +.region-output { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.region-output-empty { + padding: 1.5rem; + text-align: center; +} + +.region-output-empty-text { + font-size: 0.875rem; + color: var(--pointers-text-muted); +} + +.region-output-summary { + font-size: 0.75rem; + color: var(--pointers-text-secondary); + padding: 0 0.25rem; +} + +.region-output-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.region-output-item { + border: 1px solid var(--pointers-border-secondary); + border-radius: 6px; + padding: 0.75rem; + background: var(--pointers-bg-secondary); +} + +.region-output-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.region-output-index { + font-size: 0.6875rem; + font-weight: 600; + color: var(--pointers-text-muted); + font-family: var(--ifm-font-family-monospace, monospace); +} + +.region-name-badge { + display: inline-block; + padding: 0.125rem 0.375rem; + font-size: 0.6875rem; + font-weight: 500; + font-family: var(--ifm-font-family-monospace, monospace); + border-radius: 3px; + background: var(--pointers-accent-blue-bg); + color: var(--pointers-accent-blue); +} + +.region-output-details { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.region-output-field { + display: flex; + align-items: baseline; + gap: 0.25rem; +} + +.region-field-label { + font-size: 0.6875rem; + color: var(--pointers-text-muted); +} + +.region-field-value { + font-size: 0.75rem; + font-family: var(--ifm-font-family-monospace, monospace); + color: var(--pointers-syntax-number); + background: transparent; + padding: 0; +} + +.region-output-value { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.5rem; + background: var(--pointers-data-bg); + border: 1px solid var(--pointers-data-border); + border-radius: 4px; +} + +.region-value-label { + font-size: 0.6875rem; + font-weight: 600; + color: var(--pointers-text-secondary); + white-space: nowrap; +} + +.region-value-data { + font-size: 0.8125rem; + font-family: var(--ifm-font-family-monospace, monospace); + color: var(--pointers-text-code); + word-break: break-all; + background: transparent; + padding: 0; +} + +.region-value-data.no-value { + color: var(--pointers-text-muted); + font-style: italic; +} + +/* Region type borders */ +.region-output-item.region-storage { + border-left: 3px solid var(--pointers-region-storage); +} + +.region-output-item.region-memory { + border-left: 3px solid var(--pointers-region-memory); +} + +.region-output-item.region-stack { + border-left: 3px solid var(--pointers-region-stack); +} + +.region-output-item.region-calldata { + border-left: 3px solid var(--pointers-region-calldata); +} + +.region-output-item.region-returndata { + border-left: 3px solid var(--pointers-region-returndata); +} + +.region-output-item.region-code { + border-left: 3px solid var(--pointers-region-code); +} + +.region-output-item.region-transient { + border-left: 3px solid var(--pointers-region-transient); +} diff --git a/packages/pointers-react/src/components/RegionOutput.tsx b/packages/pointers-react/src/components/RegionOutput.tsx new file mode 100644 index 000000000..60204d78e --- /dev/null +++ b/packages/pointers-react/src/components/RegionOutput.tsx @@ -0,0 +1,149 @@ +/** + * Component for displaying resolved regions and their values. + */ + +import React from "react"; +import type { Cursor, Data } from "@ethdebug/pointers"; +import { formatData, formatDataShort } from "#utils/mockState"; + +export interface RegionOutputProps { + /** Resolved regions to display */ + regions: Cursor.Region[]; + /** Values read from each region */ + values: Map; + /** Whether to show full hex values or shortened */ + showFullValues?: boolean; +} + +/** + * Get the location type from a region. + */ +function getLocationType(region: Cursor.Region): string { + if ("location" in region && typeof region.location === "string") { + return region.location; + } + return "unknown"; +} + +/** + * Get CSS class for a location type. + */ +function getLocationClass(location: string): string { + const locationClasses: Record = { + storage: "region-storage", + memory: "region-memory", + stack: "region-stack", + calldata: "region-calldata", + returndata: "region-returndata", + code: "region-code", + transient: "region-transient", + }; + return locationClasses[location] || "region-unknown"; +} + +/** + * Component to display a single region with its value. + */ +function RegionItem({ + region, + value, + showFullValues, + index, +}: { + region: Cursor.Region; + value: Data | undefined; + showFullValues: boolean; + index: number; +}): JSX.Element { + const location = getLocationType(region); + const locationClass = getLocationClass(location); + const name = "name" in region ? (region.name as string) : undefined; + + // Format the value + const valueDisplay = value + ? showFullValues + ? formatData(value) + : formatDataShort(value, 16) + : "(no value)"; + + return ( +
+
+ #{index + 1} + {location} + {name && {name}} +
+
+ {"slot" in region && region.slot !== undefined && ( +
+ slot: + + {formatData(region.slot)} + +
+ )} + {"offset" in region && region.offset !== undefined && ( +
+ offset: + + {formatData(region.offset)} + +
+ )} + {"length" in region && region.length !== undefined && ( +
+ length: + + {formatData(region.length)} + +
+ )} +
+
+ value: + + {valueDisplay} + +
+
+ ); +} + +/** + * Component to display all resolved regions and their values. + */ +export function RegionOutput({ + regions, + values, + showFullValues = false, +}: RegionOutputProps): JSX.Element { + if (regions.length === 0) { + return ( +
+ No regions resolved +
+ ); + } + + return ( +
+
+ {regions.length} region{regions.length !== 1 ? "s" : ""} resolved +
+
+ {regions.map((region, index) => ( + + ))} +
+
+ ); +} diff --git a/packages/pointers-react/src/components/ResolutionVisualizer.css b/packages/pointers-react/src/components/ResolutionVisualizer.css new file mode 100644 index 000000000..2a8ea49d3 --- /dev/null +++ b/packages/pointers-react/src/components/ResolutionVisualizer.css @@ -0,0 +1,251 @@ +/** + * Styles for ResolutionVisualizer component. + */ + +.resolution-visualizer { + display: flex; + flex-direction: column; + gap: 1rem; + font-family: var(--ifm-font-family-base, system-ui, sans-serif); +} + +.resolution-visualizer-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media (max-width: 768px) { + .resolution-visualizer-inputs { + grid-template-columns: 1fr; + } +} + +.resolution-visualizer-panel { + border: 1px solid var(--pointers-border-primary); + border-radius: 6px; + background: var(--pointers-bg-primary); + padding: 1rem; +} + +.panel-title { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--pointers-text-primary); +} + +/* Pointer Input */ +.pointer-input { + width: 100%; + min-height: 150px; + padding: 0.75rem; + font-family: var(--ifm-font-family-monospace, monospace); + font-size: 0.8125rem; + line-height: 1.5; + border: 1px solid var(--pointers-border-secondary); + border-radius: 4px; + background: var(--pointers-bg-code); + color: var(--pointers-text-code); + resize: vertical; +} + +.pointer-input:focus { + outline: none; + border-color: var(--pointers-border-highlight); +} + +.pointer-parse-error { + margin-top: 0.5rem; + padding: 0.5rem; + font-size: 0.75rem; + color: var(--pointers-accent-red); + background: rgba(207, 34, 46, 0.1); + border-radius: 4px; +} + +/* State Editor */ +.state-editor { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.state-editor-section { + border: 1px solid var(--pointers-border-secondary); + border-radius: 4px; + overflow: hidden; +} + +.state-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--pointers-bg-secondary); + font-size: 0.75rem; + font-weight: 600; + color: var(--pointers-text-secondary); +} + +.state-editor-add-btn { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + border: 1px solid var(--pointers-border-primary); + border-radius: 3px; + background: var(--pointers-bg-primary); + color: var(--pointers-text-secondary); + cursor: pointer; +} + +.state-editor-add-btn:hover { + background: var(--pointers-bg-hover); + color: var(--pointers-text-primary); +} + +.state-editor-empty { + padding: 0.75rem; + font-size: 0.75rem; + color: var(--pointers-text-muted); + text-align: center; +} + +.state-editor-entries { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.state-editor-entry { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.state-editor-index { + font-size: 0.6875rem; + font-family: var(--ifm-font-family-monospace, monospace); + color: var(--pointers-text-muted); + width: 2rem; +} + +.state-editor-input { + flex: 1; + padding: 0.375rem 0.5rem; + font-family: var(--ifm-font-family-monospace, monospace); + font-size: 0.75rem; + border: 1px solid var(--pointers-border-secondary); + border-radius: 3px; + background: var(--pointers-bg-code); + color: var(--pointers-text-code); +} + +.state-editor-input.slot { + flex: 0.4; +} + +.state-editor-input.value { + flex: 0.6; +} + +.state-editor-input:focus { + outline: none; + border-color: var(--pointers-border-highlight); +} + +.state-editor-arrow { + color: var(--pointers-text-muted); + font-family: var(--ifm-font-family-monospace, monospace); +} + +.state-editor-remove-btn { + padding: 0.125rem 0.375rem; + font-size: 0.875rem; + line-height: 1; + border: none; + background: transparent; + color: var(--pointers-text-muted); + cursor: pointer; +} + +.state-editor-remove-btn:hover { + color: var(--pointers-accent-red); +} + +.state-editor-textarea { + width: 100%; + padding: 0.5rem; + font-family: var(--ifm-font-family-monospace, monospace); + font-size: 0.75rem; + border: none; + background: var(--pointers-bg-code); + color: var(--pointers-text-code); + resize: vertical; +} + +.state-editor-textarea:focus { + outline: none; +} + +/* Controls */ +.resolution-visualizer-controls { + display: flex; + justify-content: center; + padding: 0.5rem 0; +} + +.resolve-button { + padding: 0.625rem 1.5rem; + font-size: 0.875rem; + font-weight: 500; + border: none; + border-radius: 6px; + background: var(--pointers-accent-blue); + color: white; + cursor: pointer; + transition: background 0.15s; +} + +.resolve-button:hover:not(:disabled) { + background: var(--ifm-color-primary-dark, #0860ca); +} + +.resolve-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Output */ +.resolution-visualizer-output { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.resolution-error { + padding: 1rem; + border: 1px solid var(--pointers-accent-red); + border-radius: 6px; + background: rgba(207, 34, 46, 0.05); +} + +.resolution-error h4 { + margin: 0 0 0.5rem 0; + font-size: 0.875rem; + color: var(--pointers-accent-red); +} + +.resolution-error pre { + margin: 0; + padding: 0.5rem; + font-size: 0.75rem; + background: var(--pointers-bg-code); + border-radius: 4px; + overflow-x: auto; +} + +.regions-panel, +.output-panel { + background: var(--pointers-bg-primary); +} diff --git a/packages/pointers-react/src/components/ResolutionVisualizer.tsx b/packages/pointers-react/src/components/ResolutionVisualizer.tsx new file mode 100644 index 000000000..043433cb7 --- /dev/null +++ b/packages/pointers-react/src/components/ResolutionVisualizer.tsx @@ -0,0 +1,327 @@ +/** + * Main component for visualizing pointer resolution. + */ + +import React, { useState, useCallback } from "react"; +import type { Pointer } from "@ethdebug/format"; +import { usePointerResolverContext } from "./PointerResolverContext.js"; +import { RegionMap } from "./RegionMap.js"; +import { RegionOutput } from "./RegionOutput.js"; + +export interface ResolutionVisualizerProps { + /** Whether to show the pointer JSON input */ + showPointerInput?: boolean; + /** Whether to show the state editor */ + showStateEditor?: boolean; + /** Whether to show the resolve button controls */ + showControls?: boolean; + /** Whether to show full hex values */ + showFullValues?: boolean; +} + +/** + * Component for editing a storage state map. + */ +function StorageEditor({ + storage, + onChange, +}: { + storage: Record; + onChange: (storage: Record) => void; +}): JSX.Element { + const entries = Object.entries(storage); + + const handleSlotChange = (oldSlot: string, newSlot: string) => { + const newStorage = { ...storage }; + const value = newStorage[oldSlot]; + delete newStorage[oldSlot]; + if (newSlot) { + newStorage[newSlot] = value; + } + onChange(newStorage); + }; + + const handleValueChange = (slot: string, value: string) => { + onChange({ ...storage, [slot]: value }); + }; + + const handleAdd = () => { + const newSlot = `0x${entries.length.toString(16).padStart(2, "0")}`; + onChange({ ...storage, [newSlot]: "0x00" }); + }; + + const handleRemove = (slot: string) => { + const newStorage = { ...storage }; + delete newStorage[slot]; + onChange(newStorage); + }; + + return ( +
+
+ Storage + +
+ {entries.length === 0 ? ( +
No storage entries
+ ) : ( +
+ {entries.map(([slot, value]) => ( +
+ handleSlotChange(slot, e.target.value)} + placeholder="slot (hex)" + /> + = + handleValueChange(slot, e.target.value)} + placeholder="value (hex)" + /> + +
+ ))} +
+ )} +
+ ); +} + +/** + * Component for editing stack entries. + */ +function StackEditor({ + stack, + onChange, +}: { + stack: Array; + onChange: (stack: Array) => void; +}): JSX.Element { + const handleEntryChange = (index: number, value: string) => { + const newStack = [...stack]; + newStack[index] = value; + onChange(newStack); + }; + + const handleAdd = () => { + onChange([...stack, "0x00"]); + }; + + const handleRemove = (index: number) => { + const newStack = [...stack]; + newStack.splice(index, 1); + onChange(newStack); + }; + + return ( +
+
+ Stack (top first) + +
+ {stack.length === 0 ? ( +
Empty stack
+ ) : ( +
+ {stack.map((entry, index) => ( +
+ [{index}] + handleEntryChange(index, e.target.value)} + placeholder="value (hex)" + /> + +
+ ))} +
+ )} +
+ ); +} + +/** + * Component for editing memory contents. + */ +function MemoryEditor({ + memory, + onChange, +}: { + memory: string | undefined; + onChange: (memory: string) => void; +}): JSX.Element { + return ( +
+
+ Memory +
+