diff --git a/packages/programs-react/src/components/CallInfoPanel.css b/packages/programs-react/src/components/CallInfoPanel.css new file mode 100644 index 000000000..1adfe5dcc --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.css @@ -0,0 +1,85 @@ +.call-info-panel { + font-size: 0.9em; +} + +.call-info-banner { + padding: 6px 10px; + border-radius: 4px; + font-weight: 500; + margin-bottom: 6px; +} + +.call-info-banner-invoke { + background: var(--programs-invoke-bg, #e8f4fd); + color: var(--programs-invoke-text, #0969da); + border-left: 3px solid var(--programs-invoke-accent, #0969da); +} + +.call-info-banner-return { + background: var(--programs-return-bg, #dafbe1); + color: var(--programs-return-text, #1a7f37); + border-left: 3px solid var(--programs-return-accent, #1a7f37); +} + +.call-info-banner-revert { + background: var(--programs-revert-bg, #ffebe9); + color: var(--programs-revert-text, #cf222e); + border-left: 3px solid var(--programs-revert-accent, #cf222e); +} + +.call-info-refs { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.call-info-ref { + display: flex; + align-items: baseline; + gap: 6px; + padding: 2px 0; +} + +.call-info-ref-label { + font-weight: 500; + color: var(--programs-text-muted, #888); + min-width: 80px; + text-align: right; + flex-shrink: 0; +} + +.call-info-ref-resolved { + font-family: monospace; + font-size: 0.9em; + word-break: break-all; +} + +.call-info-ref-error { + color: var(--programs-error, #cf222e); + font-style: italic; +} + +.call-info-ref-pending { + color: var(--programs-text-muted, #888); + font-style: italic; +} + +.call-info-ref-pointer { + margin-top: 2px; +} + +.call-info-ref-pointer summary { + cursor: pointer; + color: var(--programs-text-muted, #888); + font-size: 0.85em; +} + +.call-info-ref-pointer-json { + font-size: 0.8em; + padding: 4px 8px; + background: var(--programs-bg-code, #f6f8fa); + border-radius: 3px; + overflow-x: auto; + max-height: 200px; +} diff --git a/packages/programs-react/src/components/CallInfoPanel.tsx b/packages/programs-react/src/components/CallInfoPanel.tsx new file mode 100644 index 000000000..b18e58d78 --- /dev/null +++ b/packages/programs-react/src/components/CallInfoPanel.tsx @@ -0,0 +1,131 @@ +/** + * Panel showing call context info for the current instruction. + * + * Displays a banner for invoke/return/revert events and + * lists resolved pointer ref values (arguments, return + * data, etc.). + */ + +import React from "react"; +import { + useTraceContext, + type ResolvedCallInfo, + type ResolvedPointerRef, +} from "./TraceContext.js"; + +// CSS is expected to be imported by the consuming application +// import "./CallInfoPanel.css"; + +export interface CallInfoPanelProps { + /** Whether to show raw pointer JSON */ + showPointers?: boolean; + /** Custom class name */ + className?: string; +} + +function formatBanner(info: ResolvedCallInfo): string { + const name = info.identifier || "(anonymous)"; + + if (info.kind === "invoke") { + const prefix = + info.callType === "external" + ? "Calling (external)" + : info.callType === "create" + ? "Creating" + : "Calling"; + return `${prefix} ${name}()`; + } + + if (info.kind === "return") { + return `Returned from ${name}()`; + } + + // revert + if (info.panic !== undefined) { + return `Reverted: panic 0x${info.panic.toString(16)}`; + } + return `Reverted in ${name}()`; +} + +function bannerClassName(kind: ResolvedCallInfo["kind"]): string { + if (kind === "invoke") { + return "call-info-banner-invoke"; + } + if (kind === "return") { + return "call-info-banner-return"; + } + return "call-info-banner-revert"; +} + +/** + * Shows call context info when the current instruction + * has an invoke, return, or revert context. + */ +export function CallInfoPanel({ + showPointers = false, + className = "", +}: CallInfoPanelProps): JSX.Element | null { + const { currentCallInfo } = useTraceContext(); + + if (!currentCallInfo) { + return null; + } + + return ( +
+
+ {formatBanner(currentCallInfo)} +
+ + {currentCallInfo.pointerRefs.length > 0 && ( +
+ {currentCallInfo.pointerRefs.map((ref) => ( + + ))} +
+ )} +
+ ); +} + +interface PointerRefItemProps { + ref_: ResolvedPointerRef; + showPointer: boolean; +} + +function PointerRefItem({ + ref_, + showPointer, +}: PointerRefItemProps): JSX.Element { + return ( +
+ {ref_.label}: + + {ref_.error ? ( + + Error: {ref_.error} + + ) : ref_.value !== undefined ? ( + {ref_.value} + ) : ( + (resolving...) + )} + + + {showPointer && !!ref_.pointer && ( +
+ Pointer +
+            {JSON.stringify(ref_.pointer, null, 2)}
+          
+
+ )} +
+ ); +} diff --git a/packages/programs-react/src/components/CallStackDisplay.css b/packages/programs-react/src/components/CallStackDisplay.css new file mode 100644 index 000000000..5d928c292 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.css @@ -0,0 +1,46 @@ +.call-stack { + font-size: 0.85em; + padding: 4px 8px; +} + +.call-stack-empty { + color: var(--programs-text-muted, #888); +} + +.call-stack-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; +} + +.call-stack-separator { + color: var(--programs-text-muted, #888); + user-select: none; +} + +.call-stack-frame { + display: inline-flex; + align-items: center; + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--programs-link, #0366d6); +} + +.call-stack-frame:hover { + background: var(--programs-bg-hover, rgba(0, 0, 0, 0.05)); + border-color: var(--programs-border, #ddd); +} + +.call-stack-name { + font-weight: 500; +} + +.call-stack-parens { + color: var(--programs-text-muted, #888); +} diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx new file mode 100644 index 000000000..fbc55c994 --- /dev/null +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -0,0 +1,62 @@ +/** + * Displays the current call stack as a breadcrumb trail. + */ + +import React from "react"; +import { useTraceContext } from "./TraceContext.js"; + +// CSS is expected to be imported by the consuming application +// import "./CallStackDisplay.css"; + +export interface CallStackDisplayProps { + /** Custom class name */ + className?: string; +} + +/** + * Renders the call stack as a breadcrumb. + * + * Shows function names separated by arrows, e.g.: + * main() -> transfer() -> _update() + */ +export function CallStackDisplay({ + className = "", +}: CallStackDisplayProps): JSX.Element { + const { callStack, jumpToStep } = useTraceContext(); + + if (callStack.length === 0) { + return ( +
+ (top level) +
+ ); + } + + return ( +
+
+ {callStack.map((frame, index) => ( + + {index > 0 && ( + {" -> "} + )} + + + ))} +
+
+ ); +} diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index c21874722..4a4ef9d4c 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -14,8 +14,12 @@ import type { Pointer, Program } from "@ethdebug/format"; import { dereference, Data } from "@ethdebug/pointers"; import { type TraceStep, + type CallInfo, + type CallFrame, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, } from "#utils/mockTrace"; import { traceStepToMachineState } from "#utils/traceState"; @@ -35,6 +39,36 @@ export interface ResolvedVariable { error?: string; } +/** + * A resolved pointer ref with its label and value. + */ +export interface ResolvedPointerRef { + /** Label for this pointer (e.g., "target", "arguments") */ + label: string; + /** The raw pointer */ + pointer: unknown; + /** Resolved hex value */ + value?: string; + /** Error if resolution failed */ + error?: string; +} + +/** + * Call info with resolved pointer values. + */ +export interface ResolvedCallInfo { + /** The kind of call event */ + kind: "invoke" | "return" | "revert"; + /** Function name */ + identifier?: string; + /** Call variant for invoke contexts */ + callType?: "internal" | "external" | "create"; + /** Panic code for revert contexts */ + panic?: number; + /** Resolved pointer refs */ + pointerRefs: ResolvedPointerRef[]; +} + /** * State provided by the Trace context. */ @@ -53,6 +87,10 @@ export interface TraceState { currentInstruction: Program.Instruction | undefined; /** Variables in scope at current step */ currentVariables: ResolvedVariable[]; + /** Call stack at current step */ + callStack: CallFrame[]; + /** Call info for current instruction (if any) */ + currentCallInfo: ResolvedCallInfo | undefined; /** Whether we're at the first step */ isAtStart: boolean; /** Whether we're at the last step */ @@ -241,6 +279,93 @@ export function TraceProvider({ }; }, [extractedVars, currentStep, shouldResolve, templates]); + // Build call stack by scanning instructions up to current step + const callStack = useMemo( + () => buildCallStack(trace, pcToInstruction, currentStepIndex), + [trace, pcToInstruction, currentStepIndex], + ); + + // Extract call info for current instruction (synchronous) + const extractedCallInfo = useMemo((): CallInfo | undefined => { + if (!currentInstruction) { + return undefined; + } + return extractCallInfoFromInstruction(currentInstruction); + }, [currentInstruction]); + + // Async call info pointer resolution + const [currentCallInfo, setCurrentCallInfo] = useState< + ResolvedCallInfo | undefined + >(undefined); + + useEffect(() => { + if (!extractedCallInfo) { + setCurrentCallInfo(undefined); + return; + } + + // Immediately show call info without resolved values + const initial: ResolvedCallInfo = { + kind: extractedCallInfo.kind, + identifier: extractedCallInfo.identifier, + callType: extractedCallInfo.callType, + panic: extractedCallInfo.panic, + pointerRefs: extractedCallInfo.pointerRefs.map((ref) => ({ + label: ref.label, + pointer: ref.pointer, + value: undefined, + error: undefined, + })), + }; + setCurrentCallInfo(initial); + + if (!shouldResolve || !currentStep) { + return; + } + + let cancelled = false; + const resolved = [...initial.pointerRefs]; + + const promises = extractedCallInfo.pointerRefs.map(async (ref, index) => { + try { + const value = await resolveVariableValue( + ref.pointer as Pointer, + currentStep, + templates, + ); + if (!cancelled) { + resolved[index] = { + ...resolved[index], + value, + }; + setCurrentCallInfo({ + ...initial, + pointerRefs: [...resolved], + }); + } + } catch (err) { + if (!cancelled) { + resolved[index] = { + ...resolved[index], + error: err instanceof Error ? err.message : String(err), + }; + setCurrentCallInfo({ + ...initial, + pointerRefs: [...resolved], + }); + } + } + }); + + Promise.all(promises).catch(() => { + // Individual errors already handled + }); + + return () => { + cancelled = true; + }; + }, [extractedCallInfo, currentStep, shouldResolve, templates]); + const stepForward = useCallback(() => { setCurrentStepIndex((prev) => Math.min(prev + 1, trace.length - 1)); }, [trace.length]); @@ -272,6 +397,8 @@ export function TraceProvider({ currentStep, currentInstruction, currentVariables, + callStack, + currentCallInfo, isAtStart: currentStepIndex === 0, isAtEnd: currentStepIndex >= trace.length - 1, stepForward, diff --git a/packages/programs-react/src/components/index.ts b/packages/programs-react/src/components/index.ts index 886c22d62..222acf051 100644 --- a/packages/programs-react/src/components/index.ts +++ b/packages/programs-react/src/components/index.ts @@ -22,6 +22,8 @@ export { type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, } from "./TraceContext.js"; export { @@ -37,3 +39,10 @@ export { type VariableInspectorProps, type StackInspectorProps, } from "./VariableInspector.js"; + +export { + CallStackDisplay, + type CallStackDisplayProps, +} from "./CallStackDisplay.js"; + +export { CallInfoPanel, type CallInfoPanelProps } from "./CallInfoPanel.js"; diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 599e8ff1c..2fa17ad36 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -26,13 +26,19 @@ export { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, type VariableInspectorProps, type StackInspectorProps, + type CallStackDisplayProps, + type CallInfoPanelProps, } from "#components/index"; // Shiki utilities @@ -51,7 +57,11 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, + type CallInfo, + type CallFrame, type DynamicInstruction, type DynamicContext, type ContextThunk, @@ -67,3 +77,5 @@ export { // import "@ethdebug/programs-react/components/SourceContents.css"; // import "@ethdebug/programs-react/components/TraceControls.css"; // import "@ethdebug/programs-react/components/VariableInspector.css"; +// import "@ethdebug/programs-react/components/CallStackDisplay.css"; +// import "@ethdebug/programs-react/components/CallInfoPanel.css"; diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts index 5f750e9a0..a79f07b08 100644 --- a/packages/programs-react/src/utils/index.ts +++ b/packages/programs-react/src/utils/index.ts @@ -17,9 +17,13 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, type TraceStep, type MockTraceSpec, + type CallInfo, + type CallFrame, } from "./mockTrace.js"; export { traceStepToMachineState } from "./traceState.js"; diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 6adef9e05..f3234e735 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -100,6 +100,195 @@ function extractVariablesFromContext( return []; } +/** + * Info about a function call context on an instruction. + */ +export interface CallInfo { + /** The kind of call event */ + kind: "invoke" | "return" | "revert"; + /** Function name (from Function.Identity) */ + identifier?: string; + /** Call variant for invoke contexts */ + callType?: "internal" | "external" | "create"; + /** Panic code for revert contexts */ + panic?: number; + /** Named pointer refs to resolve */ + pointerRefs: Array<{ + label: string; + pointer: unknown; + }>; +} + +/** + * Extract call info (invoke/return/revert) from an + * instruction's context tree. + */ +export function extractCallInfoFromInstruction( + instruction: Program.Instruction, +): CallInfo | undefined { + if (!instruction.context) { + return undefined; + } + return extractCallInfoFromContext(instruction.context); +} + +function extractCallInfoFromContext( + context: Program.Context, +): CallInfo | undefined { + // Use unknown intermediate to avoid strict type checks + // on the context union — we discriminate by key presence + const ctx = context as unknown as Record; + + if ("invoke" in ctx) { + const inv = ctx.invoke as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + + let callType: CallInfo["callType"]; + if ("jump" in inv) { + callType = "internal"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "arguments", inv.arguments); + } else if ("message" in inv) { + callType = "external"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "gas", inv.gas); + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "input", inv.input); + } else if ("create" in inv) { + callType = "create"; + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "salt", inv.salt); + collectPointerRef(pointerRefs, "input", inv.input); + } + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + pointerRefs, + }; + } + + if ("return" in ctx) { + const ret = ctx.return as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "data", ret.data); + collectPointerRef(pointerRefs, "success", ret.success); + + return { + kind: "return", + identifier: ret.identifier as string | undefined, + pointerRefs, + }; + } + + if ("revert" in ctx) { + const rev = ctx.revert as Record; + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "reason", rev.reason); + + return { + kind: "revert", + identifier: rev.identifier as string | undefined, + panic: rev.panic as number | undefined, + pointerRefs, + }; + } + + // Walk gather/pick to find call info + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const sub of ctx.gather as Program.Context[]) { + const info = extractCallInfoFromContext(sub); + if (info) { + return info; + } + } + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + for (const sub of ctx.pick as Program.Context[]) { + const info = extractCallInfoFromContext(sub); + if (info) { + return info; + } + } + } + + return undefined; +} + +function collectPointerRef( + refs: CallInfo["pointerRefs"], + label: string, + value: unknown, +): void { + if (value && typeof value === "object" && "pointer" in value) { + refs.push({ label, pointer: (value as { pointer: unknown }).pointer }); + } +} + +/** + * A frame in the call stack. + */ +export interface CallFrame { + /** Function name */ + identifier?: string; + /** The step index where this call was invoked */ + stepIndex: number; + /** The call type */ + callType?: "internal" | "external" | "create"; +} + +/** + * Build a call stack by scanning instructions from + * step 0 to the given step index. + */ +export function buildCallStack( + trace: TraceStep[], + pcToInstruction: Map, + upToStep: number, +): CallFrame[] { + const stack: CallFrame[] = []; + + for (let i = 0; i <= upToStep && i < trace.length; i++) { + const step = trace[i]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction) { + continue; + } + + const callInfo = extractCallInfoFromInstruction(instruction); + if (!callInfo) { + continue; + } + + if (callInfo.kind === "invoke") { + // The compiler emits invoke on both the caller JUMP and + // callee entry JUMPDEST. Skip if the top frame already + // matches this call. + const top = stack[stack.length - 1]; + if ( + !top || + top.identifier !== callInfo.identifier || + top.callType !== callInfo.callType + ) { + stack.push({ + identifier: callInfo.identifier, + stepIndex: i, + callType: callInfo.callType, + }); + } + } else if (callInfo.kind === "return" || callInfo.kind === "revert") { + // Pop the matching frame + if (stack.length > 0) { + stack.pop(); + } + } + } + + return stack; +} + /** * Build a map of PC to instruction for quick lookup. */ diff --git a/packages/web/src/theme/ProgramExample/CallInfoPanel.css b/packages/web/src/theme/ProgramExample/CallInfoPanel.css new file mode 100644 index 000000000..1adfe5dcc --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallInfoPanel.css @@ -0,0 +1,85 @@ +.call-info-panel { + font-size: 0.9em; +} + +.call-info-banner { + padding: 6px 10px; + border-radius: 4px; + font-weight: 500; + margin-bottom: 6px; +} + +.call-info-banner-invoke { + background: var(--programs-invoke-bg, #e8f4fd); + color: var(--programs-invoke-text, #0969da); + border-left: 3px solid var(--programs-invoke-accent, #0969da); +} + +.call-info-banner-return { + background: var(--programs-return-bg, #dafbe1); + color: var(--programs-return-text, #1a7f37); + border-left: 3px solid var(--programs-return-accent, #1a7f37); +} + +.call-info-banner-revert { + background: var(--programs-revert-bg, #ffebe9); + color: var(--programs-revert-text, #cf222e); + border-left: 3px solid var(--programs-revert-accent, #cf222e); +} + +.call-info-refs { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.call-info-ref { + display: flex; + align-items: baseline; + gap: 6px; + padding: 2px 0; +} + +.call-info-ref-label { + font-weight: 500; + color: var(--programs-text-muted, #888); + min-width: 80px; + text-align: right; + flex-shrink: 0; +} + +.call-info-ref-resolved { + font-family: monospace; + font-size: 0.9em; + word-break: break-all; +} + +.call-info-ref-error { + color: var(--programs-error, #cf222e); + font-style: italic; +} + +.call-info-ref-pending { + color: var(--programs-text-muted, #888); + font-style: italic; +} + +.call-info-ref-pointer { + margin-top: 2px; +} + +.call-info-ref-pointer summary { + cursor: pointer; + color: var(--programs-text-muted, #888); + font-size: 0.85em; +} + +.call-info-ref-pointer-json { + font-size: 0.8em; + padding: 4px 8px; + background: var(--programs-bg-code, #f6f8fa); + border-radius: 3px; + overflow-x: auto; + max-height: 200px; +} diff --git a/packages/web/src/theme/ProgramExample/CallStackDisplay.css b/packages/web/src/theme/ProgramExample/CallStackDisplay.css new file mode 100644 index 000000000..5d928c292 --- /dev/null +++ b/packages/web/src/theme/ProgramExample/CallStackDisplay.css @@ -0,0 +1,46 @@ +.call-stack { + font-size: 0.85em; + padding: 4px 8px; +} + +.call-stack-empty { + color: var(--programs-text-muted, #888); +} + +.call-stack-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; +} + +.call-stack-separator { + color: var(--programs-text-muted, #888); + user-select: none; +} + +.call-stack-frame { + display: inline-flex; + align-items: center; + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--programs-link, #0366d6); +} + +.call-stack-frame:hover { + background: var(--programs-bg-hover, rgba(0, 0, 0, 0.05)); + border-color: var(--programs-border, #ddd); +} + +.call-stack-name { + font-weight: 500; +} + +.call-stack-parens { + color: var(--programs-text-muted, #888); +} diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.css b/packages/web/src/theme/ProgramExample/TraceDrawer.css index 4da55e7be..9f36d24c0 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.css +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.css @@ -127,6 +127,83 @@ text-align: center; } +/* Call stack breadcrumb */ +.call-stack-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + padding: 4px 12px; + font-size: 12px; + background: var(--ifm-background-surface-color); + border-bottom: 1px solid var(--ifm-color-emphasis-200); + flex-shrink: 0; +} + +.call-stack-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ifm-color-content-secondary); + margin-right: 4px; +} + +.call-stack-toplevel { + color: var(--ifm-color-content-secondary); + font-style: italic; +} + +.call-stack-sep { + color: var(--ifm-color-content-secondary); + padding: 0 2px; + user-select: none; +} + +.call-stack-frame-btn { + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px 4px; + cursor: pointer; + font-family: var(--ifm-font-family-monospace); + font-size: 12px; + font-weight: 500; + color: var(--ifm-color-primary); +} + +.call-stack-frame-btn:hover { + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-300); +} + +/* Call info banner */ +.call-info-bar { + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + flex-shrink: 0; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.call-info-invoke { + background: var(--ifm-color-info-contrast-background); + color: var(--ifm-color-info-darkest); + border-left: 3px solid var(--ifm-color-info); +} + +.call-info-return { + background: var(--ifm-color-success-contrast-background); + color: var(--ifm-color-success-darkest); + border-left: 3px solid var(--ifm-color-success); +} + +.call-info-revert { + background: var(--ifm-color-danger-contrast-background); + color: var(--ifm-color-danger-darkest); + border-left: 3px solid var(--ifm-color-danger); +} + /* Trace panels */ .trace-panels { display: grid; diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 72c69418b..0cae58678 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -85,6 +85,62 @@ function TraceDrawerContent(): JSX.Element { return extractVariables(instruction.debug.context); }, [trace, currentStep, pcToInstruction]); + // Extract call info from current instruction context + const currentCallInfo = useMemo(() => { + if (trace.length === 0 || currentStep >= trace.length) { + return undefined; + } + + const step = trace[currentStep]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction?.debug?.context) return undefined; + + return extractCallInfo(instruction.debug.context); + }, [trace, currentStep, pcToInstruction]); + + // Build call stack by scanning invoke/return/revert up to + // current step + const callStack = useMemo(() => { + const frames: Array<{ + identifier?: string; + stepIndex: number; + callType?: string; + }> = []; + + for (let i = 0; i <= currentStep && i < trace.length; i++) { + const step = trace[i]; + const instruction = pcToInstruction.get(step.pc); + if (!instruction?.debug?.context) continue; + + const info = extractCallInfo(instruction.debug.context); + if (!info) continue; + + if (info.kind === "invoke") { + // The compiler emits invoke on both the caller + // JUMP and callee entry JUMPDEST. Skip if the + // top frame already matches this call. + const top = frames[frames.length - 1]; + if ( + !top || + top.identifier !== info.identifier || + top.callType !== info.callType + ) { + frames.push({ + identifier: info.identifier, + stepIndex: i, + callType: info.callType, + }); + } + } else if (info.kind === "return" || info.kind === "revert") { + if (frames.length > 0) { + frames.pop(); + } + } + } + + return frames; + }, [trace, currentStep, pcToInstruction]); + // Compile source and run trace in one shot. // Takes source directly to avoid stale-state issues. const compileAndTrace = useCallback(async (sourceCode: string) => { @@ -298,6 +354,36 @@ function TraceDrawerContent(): JSX.Element { +
+ Call Stack: + {callStack.length === 0 ? ( + (top level) + ) : ( + callStack.map((frame, i) => ( + + {i > 0 && ( + + )} + + + )) + )} +
+ + {currentCallInfo && ( +
+ {formatCallBanner(currentCallInfo)} +
+ )} +
Instructions
@@ -468,6 +554,90 @@ function VariablesDisplay({ variables }: VariablesDisplayProps): JSX.Element { ); } +/** + * Info about a call context (invoke/return/revert). + */ +interface CallInfoResult { + kind: "invoke" | "return" | "revert"; + identifier?: string; + callType?: string; +} + +/** + * Extract call info from an ethdebug format context object. + */ +function extractCallInfo(context: unknown): CallInfoResult | undefined { + if (!context || typeof context !== "object") { + return undefined; + } + + const ctx = context as Record; + + if ("invoke" in ctx && ctx.invoke) { + const inv = ctx.invoke as Record; + let callType: string | undefined; + if ("jump" in inv) callType = "internal"; + else if ("message" in inv) callType = "external"; + else if ("create" in inv) callType = "create"; + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + }; + } + + if ("return" in ctx && ctx.return) { + const ret = ctx.return as Record; + return { + kind: "return", + identifier: ret.identifier as string | undefined, + }; + } + + if ("revert" in ctx && ctx.revert) { + const rev = ctx.revert as Record; + return { + kind: "revert", + identifier: rev.identifier as string | undefined, + }; + } + + // Walk gather/pick + if ("gather" in ctx && Array.isArray(ctx.gather)) { + for (const sub of ctx.gather) { + const info = extractCallInfo(sub); + if (info) return info; + } + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + for (const sub of ctx.pick) { + const info = extractCallInfo(sub); + if (info) return info; + } + } + + return undefined; +} + +/** + * Format a call info banner string. + */ +function formatCallBanner(info: CallInfoResult): string { + const name = info.identifier || "(anonymous)"; + switch (info.kind) { + case "invoke": { + const prefix = info.callType === "create" ? "Creating" : "Calling"; + return `${prefix} ${name}()`; + } + case "return": + return `Returned from ${name}()`; + case "revert": + return `Reverted in ${name}()`; + } +} + /** * Extract variables from an ethdebug format context object. */ diff --git a/packages/web/src/theme/ProgramExample/TraceViewer.tsx b/packages/web/src/theme/ProgramExample/TraceViewer.tsx index 59ee577f0..c2348beb7 100644 --- a/packages/web/src/theme/ProgramExample/TraceViewer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceViewer.tsx @@ -12,6 +12,8 @@ import { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, useTraceContext, type TraceStep, } from "@ethdebug/programs-react"; @@ -20,6 +22,8 @@ import { import "./TraceViewer.css"; import "./TraceControls.css"; import "./VariableInspector.css"; +import "./CallStackDisplay.css"; +import "./CallInfoPanel.css"; export interface TraceViewerProps { /** The execution trace */ @@ -97,6 +101,7 @@ function TraceViewerContent({
+
@@ -118,6 +123,8 @@ function TraceViewerContent({
+ + {showVariables && (

Variables

diff --git a/packages/web/src/theme/ProgramExample/index.ts b/packages/web/src/theme/ProgramExample/index.ts index 47a0e1113..c930d1b4d 100644 --- a/packages/web/src/theme/ProgramExample/index.ts +++ b/packages/web/src/theme/ProgramExample/index.ts @@ -17,13 +17,19 @@ export { TraceProgress, VariableInspector, StackInspector, + CallStackDisplay, + CallInfoPanel, type TraceState, type TraceProviderProps, type ResolvedVariable, + type ResolvedCallInfo, + type ResolvedPointerRef, type TraceControlsProps, type TraceProgressProps, type VariableInspectorProps, type StackInspectorProps, + type CallStackDisplayProps, + type CallInfoPanelProps, } from "@ethdebug/programs-react"; // Also re-export utilities for convenience @@ -33,12 +39,16 @@ export { createMockTrace, findInstructionAtPc, extractVariablesFromInstruction, + extractCallInfoFromInstruction, buildPcToInstructionMap, + buildCallStack, type DynamicInstruction, type DynamicContext, type ContextThunk, type TraceStep, type MockTraceSpec, + type CallInfo, + type CallFrame, } from "@ethdebug/programs-react"; // Local Docusaurus-specific components