Build a Universal Code Visualizer
Build a browser-based JS interpreter that animates data structures step by step.
Introduction
30 Second Summary
Ever tried to understand how a binary tree rebalances or a linked list reverses, and wished you could just watch it happen? Most developers sketch these on paper or step through debuggers line by line.
In this project, you will build a professional browser-based tool that takes any JavaScript code, executes it step by step, auto-detects data structures from the runtime object graph, and renders them as animated visual diagrams with full time-travel debugging.
What You'll Build
A universal code visualizer that parses JavaScript into an Abstract Syntax Tree, runs it through a custom tree-walking interpreter with full state capture, classifies heap objects into canonical data structure shapes, and renders smooth animated transitions as you scrub through the execution timeline.
By the end of this project, you'll have:
- A custom AST interpreter that supports classes, closures, recursion, destructuring, and 20+ built-in methods, running inside a sandboxed Web Worker with timeout protection.
- A weighted heuristic shape classifier that auto-detects binary trees, linked lists, graphs, hash maps, and more from runtime object property patterns.
- An animated visualization frontend with breakpoint gutters, heap diffing, watch expressions, a Reingold-Tilford tree layout algorithm, and a keyboard-controlled timeline.
- Secret Mission: A force-directed graph renderer with labeled edges, cycle detection, and weighted edge visualization for arbitrary object graphs.
Are there any prerequisites?
You need Node.js installed and basic familiarity with JavaScript (variables, functions, objects). This project uses React and TypeScript, but you do not need prior experience with either.
Project Scaffold and Dependencies
To build a universal code visualizer, you need a modern frontend framework that compiles fast and a set of specialized libraries for parsing JavaScript and editing code in the browser.
In this step, you'll scaffold a React + TypeScript project with Vite, install the parsing and editor libraries, and set up the directory structure for all modules.
In this step, get ready to:
- Scaffold a Vite + React + TypeScript project.
- Install the Acorn parser and CodeMirror editor libraries.
- Create the project directory structure for all modules.
Scaffold the Vite project
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
What to expect from the scaffold command
When you run the next command, npm may ask "Need to install the following packages: create-vite@latest. Ok to proceed?" Type y and press Enter to continue.
- Scaffold the project, enter it, and install its base dependencies by running these commands:
npm create vite@latest code-visualizer -- --template react-ts
cd code-visualizer
npm install
What do these commands do?
- npm create vite@latest code-visualizer -- --template react-ts scaffolds a new project called code-visualizer using the React + TypeScript template.
- cd code-visualizer moves you into the new project folder.
- npm install downloads all the starter dependencies listed in package.json.
- Confirm the project was created by running:
ls
You should see files like package.json, vite.config.ts, tsconfig.json, and a src/ folder in the output.
Seeing "command not found" or permission errors?
- Make sure Node.js is installed and your terminal session is fresh. Close and reopen your terminal, then try again.
- If npm asks about packages and you accidentally declined, re-run the npm create command and type y when prompted.
- Vite 8 requires Node.js 20.19+ or 22.12+. Run node --version to check yours.
Help me troubleshoot the Vite scaffold command failing on my machine.
Why Vite?
Vite 8 uses a Rust-based bundler (Rolldown) that starts the dev server almost instantly, even as the project grows. It also supports hot module replacement, so changes appear in the browser without a full page reload.
For a visualization-heavy project like this, fast feedback loops make a big difference during development.
✔️ Awesome, I've got everything!
Great. Make sure you're inside the code-visualizer folder before continuing.
ⓧ I'd like to double check the full code
Here's what your vite.config.ts should look like:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
And here's src/main.tsx:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
Install the parsing and editor libraries
The visualizer relies on Acorn to parse JavaScript into an Abstract Syntax Tree, and CodeMirror to provide a professional code editor with syntax highlighting.
- Install all the parsing and editor packages by running:
npm install acorn acorn-walk @uiw/react-codemirror @codemirror/lang-javascript @codemirror/view @codemirror/state
What are these packages?
- acorn is a tiny, fast JavaScript parser that produces an ESTree-compatible AST. Your interpreter will walk this tree to execute code step by step.
- acorn-walk provides helpers for traversing the AST nodes that Acorn produces.
- @uiw/react-codemirror wraps the CodeMirror 6 editor as a React component.
- @codemirror/lang-javascript, @codemirror/view, and @codemirror/state add JavaScript language support, view extensions, and state management for the editor.
- Confirm the packages installed successfully by checking the output. You should see "added X packages" with no errors.
Install failing with network or peer dependency errors?
- Check your internet connection and try running the install command again.
- If you see peer dependency warnings (not errors), those are usually safe to ignore. The packages still install.
- Make sure you're inside the code-visualizer folder. Run pwd to confirm your current directory.
Help me fix npm install errors for my code visualizer project.
Verify the dev server and create the directory structure
Before building the interpreter and visualizer, confirm the base project runs correctly and create the folders where each module will live.
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:5173.
You should see the default Vite + React welcome page with a spinning logo and a counter button.
Page not loading at localhost:5173?
- Check your terminal output for the actual URL. Vite prints the local address when it starts. If port 5173 is taken, it picks the next available port.
- If the terminal shows an error about missing modules, run npm install again and then npm run dev.
Help me get my Vite dev server running. I'm not seeing the page at localhost:5173.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Create the project's module directories by running:
mkdir src/interpreter src/classifier src/visualizer src/worker src/components src/layouts src/hooks
- Confirm the directories exist by running:
ls src/
You should see all seven new folders (classifier, components, hooks, interpreter, layouts, visualizer, worker) alongside the existing files like App.tsx and main.tsx.
What is each directory for?
- interpreter/ holds the AST interpreter, environment (scope chain), and heap manager.
- classifier/ holds the heuristic system that auto-detects data structure shapes from runtime objects.
- visualizer/ holds the animated SVG renderers for trees, lists, and graphs.
- worker/ holds the Web Worker that sandboxes execution.
- components/ holds the React UI components (editor, timeline, inspector panels).
- layouts/ holds the graph layout algorithms (Reingold-Tilford, force-directed).
- hooks/ holds custom React hooks for interpreter state and keyboard controls.
Getting "No such file or directory" from mkdir?
- Make sure you're in the code-visualizer root folder (where package.json lives). The src/ directory must already exist for the command to work.
- Run pwd to check your location. You should see a path ending in code-visualizer.
Help me fix the mkdir command for creating my project directories.
Your project scaffold is ready. Next up, you'll build the type system and execution model that powers the interpreter.
Build the Interpreter Type System and Environment
Your project scaffold is ready. Now you need to build the foundation that the entire interpreter will run on.
Every time your interpreter executes a line of code, it needs to capture the full program state: which variables exist, what objects live on the heap, and where the call stack sits. This step creates the type system, scope management, and heap tracking that make those snapshots possible.
In this step, get ready to:
- Define the core type system for execution steps, heap objects, and configuration.
- Build a lexical environment class with closure support and var/let/const semantics.
- Implement a managed heap with snapshotting and structural diffing.
Define the interpreter type system
The interpreter needs a shared vocabulary of types that every module can rely on. These interfaces define the shape of heap objects, stack frames, execution steps, and configuration.
- Create a new file called types.ts inside the src/interpreter/ folder, then paste the following code:
export interface HeapObject {
id: number
type: string
properties: Record<string, any>
generation: number
}
export interface StackFrame {
name: string
line: number
scopeId: string
}
export interface StepDiff {
created: number[]
modified: number[]
deleted: number[]
changedVars: string[]
}
export interface ExecutionStep {
line: number
column: number
nodeType: string
variables: Record<string, any>
heap: Map<number, HeapObject>
callStack: StackFrame[]
stdout: string[]
diff: StepDiff | null
}
export interface InterpreterConfig {
maxSteps: number
timeoutMs: number
breakpoints: number[]
watchExpressions: string[]
}
export interface InterpreterResult {
steps: ExecutionStep[]
error: string | null
executionTimeMs: number
}
What does this code do?
Each interface defines a piece of the execution model:
- HeapObject tracks every object allocated during execution. The generation counter marks when the object was last modified, which powers the diff system later.
- StackFrame captures one level of the call stack. The scopeId links a frame to its lexical environment so you can trace closures.
- ExecutionStep is a full snapshot of the program at one point in time: all variables, the heap, call stack, console output, and what changed since the last step.
- InterpreterConfig controls execution limits and features like breakpoints and watch expressions.
- Save src/interpreter/types.ts.
Seeing red squiggles in your editor?
If TypeScript shows errors, make sure you saved the file with the exact .ts extension (not .tsx). This file contains only type definitions with no JSX.
help me debug TypeScript errors in my types.ts file
Build the lexical environment class
JavaScript has three variable scoping rules: var hoists to the enclosing function, while let and const stay block-scoped. Your Environment class models this with a linked chain of scopes, where each scope knows its parent.
- Create a new file called environment.ts inside src/interpreter/, then paste this code:
export type BindingKind = 'var' | 'let' | 'const'
interface Binding {
value: any
kind: BindingKind
initialized: boolean
}
let envIdCounter = 0
export class Environment {
public readonly id: string
private bindings: Map<string, Binding> = new Map()
private parent: Environment | null
constructor(parent: Environment | null = null) {
this.parent = parent
this.id = `env_${envIdCounter++}`
}
define(name: string, value: any, kind: BindingKind = 'let'): void {
this.bindings.set(name, { value, kind, initialized: true })
}
declareVar(name: string): void {
const funcScope = this.findFunctionScope()
if (!funcScope.bindings.has(name)) {
funcScope.bindings.set(name, { value: undefined, kind: 'var', initialized: true })
}
}
get(name: string): any {
const binding = this.resolve(name)
if (!binding) return undefined
if (!binding.initialized) {
throw new Error(`Cannot access '${name}' before initialization`)
}
return binding.value
}
set(name: string, value: any): void {
const env = this.findOwner(name)
if (env) {
const binding = env.bindings.get(name)!
if (binding.kind === 'const') {
throw new Error(`Assignment to constant variable '${name}'`)
}
binding.value = value
} else {
this.bindings.set(name, { value, kind: 'var', initialized: true })
}
}
has(name: string): boolean {
return this.resolve(name) !== null
}
getAll(): Record<string, any> {
const vars: Record<string, any> = {}
if (this.parent) {
Object.assign(vars, this.parent.getAll())
}
this.bindings.forEach((binding, key) => {
if (binding.initialized) {
vars[key] = binding.value
}
})
return vars
}
extend(): Environment {
return new Environment(this)
}
private resolve(name: string): Binding | null {
if (this.bindings.has(name)) {
return this.bindings.get(name)!
}
if (this.parent) {
return this.parent.resolve(name)
}
return null
}
private findOwner(name: string): Environment | null {
if (this.bindings.has(name)) return this
if (this.parent) return this.parent.findOwner(name)
return null
}
private findFunctionScope(): Environment {
return this.parent ? this.parent.findFunctionScope() : this
}
}
What does this code do?
The Environment class is a linked list of scopes. Each environment has a unique id (used later for diff tracking), a Map of bindings, and a pointer to its parent scope.
The resolve() method walks up the chain to find a variable. The findFunctionScope() method walks to the top-level scope for var hoisting. Assigning to a const throws an error, just like real JavaScript.
The getAll() method recursively merges all visible variables from every scope in the chain. This is how the interpreter captures a full snapshot of all accessible variables at any execution step.
- Save src/interpreter/environment.ts.
TypeScript errors about private members?
Make sure the Binding interface is defined above the Environment class. TypeScript needs to see type definitions before they're used in the same file.
help me fix TypeScript errors in my environment.ts
Implement the managed heap with diffing
When JavaScript code creates objects (new Node(5), { left: null }), your interpreter needs to track them separately from primitive variables. The managed heap allocates objects with unique IDs, takes full snapshots for time-travel, and diffs consecutive snapshots to show what changed.
- Create a new file called heap.ts inside src/interpreter/, then paste this code:
import { HeapObject, StepDiff } from './types'
export class ManagedHeap {
private objects: Map<number, HeapObject> = new Map()
private nextId = 1
private generation = 0
allocate(type: string, properties: Record<string, any> = {}): { ref: { __heapId: number }, obj: HeapObject } {
const id = this.nextId++
const obj: HeapObject = { id, type, properties, generation: this.generation }
this.objects.set(id, obj)
return { ref: { __heapId: id }, obj }
}
get(id: number): HeapObject | undefined {
return this.objects.get(id)
}
setProperty(id: number, prop: string, value: any): void {
const obj = this.objects.get(id)
if (obj) {
obj.properties[prop] = value
obj.generation = this.generation
}
}
snapshot(): Map<number, HeapObject> {
const clone = new Map<number, HeapObject>()
this.objects.forEach((obj, id) => {
clone.set(id, {
id: obj.id,
type: obj.type,
properties: this.deepCloneProperties(obj.properties),
generation: obj.generation,
})
})
return clone
}
incrementGeneration(): void {
this.generation++
}
static diff(prev: Map<number, HeapObject> | null, current: Map<number, HeapObject>): StepDiff {
if (!prev) {
return { created: [...current.keys()], modified: [], deleted: [], changedVars: [] }
}
const created: number[] = []
const modified: number[] = []
const deleted: number[] = []
current.forEach((obj, id) => {
if (!prev.has(id)) {
created.push(id)
} else {
const prevObj = prev.get(id)!
if (JSON.stringify(prevObj.properties) !== JSON.stringify(obj.properties)) {
modified.push(id)
}
}
})
prev.forEach((_, id) => {
if (!current.has(id)) {
deleted.push(id)
}
})
return { created, modified, deleted, changedVars: [] }
}
private deepCloneProperties(props: Record<string, any>): Record<string, any> {
const clone: Record<string, any> = {}
for (const [key, value] of Object.entries(props)) {
if (value === null || value === undefined) {
clone[key] = value
} else if (Array.isArray(value)) {
clone[key] = [...value]
} else if (typeof value === 'object' && value.__heapId) {
clone[key] = { __heapId: value.__heapId }
} else if (typeof value === 'object' && !value.__isFunction && !value.__isClass) {
clone[key] = { ...value }
} else {
clone[key] = value
}
}
return clone
}
}
What does this code do?
The ManagedHeap is where all runtime objects live. Every new Node() or object literal gets an auto-incrementing ID and a slot in the map.
- allocate() creates a new object and returns both a reference (used in variable bindings) and the object itself.
- snapshot() deep-clones the entire heap so each execution step has its own frozen copy. This is what enables time-travel scrubbing.
- diff() compares two heap snapshots and returns which objects were created, modified, or deleted. The visualization layer uses this to highlight changes.
- deepCloneProperties() handles heap references (__heapId pointers) specially so they stay as references rather than being recursively copied.
- Save src/interpreter/heap.ts.
- Confirm the files exist by running:
ls src/interpreter/
You should see types.ts, environment.ts, and heap.ts listed.
Import errors in heap.ts?
The import { HeapObject, StepDiff } from './types' line at the top of heap.ts depends on types.ts existing in the same folder. If you see a "cannot find module" error, check that types.ts is saved in src/interpreter/ (not accidentally placed in src/).
help me fix import errors in heap.ts
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here are all three files for comparison:
export interface HeapObject {
id: number
type: string
properties: Record<string, any>
generation: number
}
export interface StackFrame {
name: string
line: number
scopeId: string
}
export interface StepDiff {
created: number[]
modified: number[]
deleted: number[]
changedVars: string[]
}
export interface ExecutionStep {
line: number
column: number
nodeType: string
variables: Record<string, any>
heap: Map<number, HeapObject>
callStack: StackFrame[]
stdout: string[]
diff: StepDiff | null
}
export interface InterpreterConfig {
maxSteps: number
timeoutMs: number
breakpoints: number[]
watchExpressions: string[]
}
export interface InterpreterResult {
steps: ExecutionStep[]
error: string | null
executionTimeMs: number
}
export type BindingKind = 'var' | 'let' | 'const'
interface Binding {
value: any
kind: BindingKind
initialized: boolean
}
let envIdCounter = 0
export class Environment {
public readonly id: string
private bindings: Map<string, Binding> = new Map()
private parent: Environment | null
constructor(parent: Environment | null = null) {
this.parent = parent
this.id = `env_${envIdCounter++}`
}
define(name: string, value: any, kind: BindingKind = 'let'): void {
this.bindings.set(name, { value, kind, initialized: true })
}
declareVar(name: string): void {
const funcScope = this.findFunctionScope()
if (!funcScope.bindings.has(name)) {
funcScope.bindings.set(name, { value: undefined, kind: 'var', initialized: true })
}
}
get(name: string): any {
const binding = this.resolve(name)
if (!binding) return undefined
if (!binding.initialized) {
throw new Error(`Cannot access '${name}' before initialization`)
}
return binding.value
}
set(name: string, value: any): void {
const env = this.findOwner(name)
if (env) {
const binding = env.bindings.get(name)!
if (binding.kind === 'const') {
throw new Error(`Assignment to constant variable '${name}'`)
}
binding.value = value
} else {
this.bindings.set(name, { value, kind: 'var', initialized: true })
}
}
has(name: string): boolean {
return this.resolve(name) !== null
}
getAll(): Record<string, any> {
const vars: Record<string, any> = {}
if (this.parent) {
Object.assign(vars, this.parent.getAll())
}
this.bindings.forEach((binding, key) => {
if (binding.initialized) {
vars[key] = binding.value
}
})
return vars
}
extend(): Environment {
return new Environment(this)
}
private resolve(name: string): Binding | null {
if (this.bindings.has(name)) {
return this.bindings.get(name)!
}
if (this.parent) {
return this.parent.resolve(name)
}
return null
}
private findOwner(name: string): Environment | null {
if (this.bindings.has(name)) return this
if (this.parent) return this.parent.findOwner(name)
return null
}
private findFunctionScope(): Environment {
return this.parent ? this.parent.findFunctionScope() : this
}
}
import { HeapObject, StepDiff } from './types'
export class ManagedHeap {
private objects: Map<number, HeapObject> = new Map()
private nextId = 1
private generation = 0
allocate(type: string, properties: Record<string, any> = {}): { ref: { __heapId: number }, obj: HeapObject } {
const id = this.nextId++
const obj: HeapObject = { id, type, properties, generation: this.generation }
this.objects.set(id, obj)
return { ref: { __heapId: id }, obj }
}
get(id: number): HeapObject | undefined {
return this.objects.get(id)
}
setProperty(id: number, prop: string, value: any): void {
const obj = this.objects.get(id)
if (obj) {
obj.properties[prop] = value
obj.generation = this.generation
}
}
snapshot(): Map<number, HeapObject> {
const clone = new Map<number, HeapObject>()
this.objects.forEach((obj, id) => {
clone.set(id, {
id: obj.id,
type: obj.type,
properties: this.deepCloneProperties(obj.properties),
generation: obj.generation,
})
})
return clone
}
incrementGeneration(): void {
this.generation++
}
static diff(prev: Map<number, HeapObject> | null, current: Map<number, HeapObject>): StepDiff {
if (!prev) {
return { created: [...current.keys()], modified: [], deleted: [], changedVars: [] }
}
const created: number[] = []
const modified: number[] = []
const deleted: number[] = []
current.forEach((obj, id) => {
if (!prev.has(id)) {
created.push(id)
} else {
const prevObj = prev.get(id)!
if (JSON.stringify(prevObj.properties) !== JSON.stringify(obj.properties)) {
modified.push(id)
}
}
})
prev.forEach((_, id) => {
if (!current.has(id)) {
deleted.push(id)
}
})
return { created, modified, deleted, changedVars: [] }
}
private deepCloneProperties(props: Record<string, any>): Record<string, any> {
const clone: Record<string, any> = {}
for (const [key, value] of Object.entries(props)) {
if (value === null || value === undefined) {
clone[key] = value
} else if (Array.isArray(value)) {
clone[key] = [...value]
} else if (typeof value === 'object' && value.__heapId) {
clone[key] = { __heapId: value.__heapId }
} else if (typeof value === 'object' && !value.__isFunction && !value.__isClass) {
clone[key] = { ...value }
} else {
clone[key] = value
}
}
return clone
}
}
Your type system, scope chain, and heap are locked in. Next up, you'll build the actual interpreter that walks the AST and uses these modules to execute JavaScript statement by statement.
Build the AST Interpreter Core
Your type system, lexical environment, and managed heap are all in place. These provide the foundation for the real engine: the interpreter that walks an AST node-by-node, executes each statement, and captures a full program snapshot at every step.
In this step, you will build a complete tree-walking interpreter inside a single Interpreter class. It parses JavaScript with Acorn, dispatches every statement and expression type, handles closures and classes, and records an ExecutionStep with a heap snapshot at every meaningful AST node.
In this step, get ready to:
- Build the statement dispatcher that handles 16+ statement types including loops, conditionals, try/catch, and switch.
- Implement the expression evaluator covering all operators, member access, object/array construction, and template literals.
- Add class instantiation, closures, and built-in methods for console, arrays, Math, and Object.
Create the interpreter with Acorn parsing and statement handling
The interpreter class is the execution engine for your visualizer. It parses code into an AST using Acorn, then walks each node, executing statements one by one and snapshotting the full program state after each.
Why a tree-walking interpreter?
A custom tree-walking interpreter lets you capture state at EVERY AST node visit. JavaScript's built-in eval() cannot be paused mid-execution to record variables or heap state. By interpreting the AST yourself, you control the execution model completely and can snapshot after every single statement.
- Create a new file src/interpreter/interpreter.ts and add the imports, configuration, and statement handling section by pasting the following code:
import * as acorn from 'acorn'
import { Environment } from './environment'
import { ManagedHeap } from './heap'
import { ExecutionStep, HeapObject, InterpreterConfig, InterpreterResult, StackFrame, StepDiff } from './types'
const DEFAULT_CONFIG: InterpreterConfig = {
maxSteps: 2000,
timeoutMs: 10000,
breakpoints: [],
watchExpressions: [],
}
export class Interpreter {
private steps: ExecutionStep[] = []
private heap: ManagedHeap = new ManagedHeap()
private callStack: StackFrame[] = []
private stdout: string[] = []
private stepCount = 0
private config: InterpreterConfig = DEFAULT_CONFIG
private startTime = 0
private previousHeap: Map<number, HeapObject> | null = null
run(code: string, config: Partial<InterpreterConfig> = {}): InterpreterResult {
this.steps = []
this.heap = new ManagedHeap()
this.callStack = [{ name: 'global', line: 1, scopeId: 'global' }]
this.stdout = []
this.stepCount = 0
this.config = { ...DEFAULT_CONFIG, ...config }
this.startTime = Date.now()
this.previousHeap = null
try {
const ast = acorn.parse(code, {
ecmaVersion: 'latest',
sourceType: 'script',
locations: true,
}) as any
const globalEnv = new Environment()
this.executeBlock(ast.body, globalEnv)
return {
steps: this.steps,
error: null,
executionTimeMs: Date.now() - this.startTime,
}
} catch (e: any) {
return {
steps: this.steps,
error: e.message,
executionTimeMs: Date.now() - this.startTime,
}
}
}
private checkLimits(): void {
if (this.stepCount >= this.config.maxSteps) {
throw new Error(`Execution limit reached (${this.config.maxSteps} steps)`)
}
if (Date.now() - this.startTime > this.config.timeoutMs) {
throw new Error(`Execution timeout (${this.config.timeoutMs}ms)`)
}
}
private captureStep(line: number, column: number, nodeType: string, env: Environment): void {
this.checkLimits()
this.stepCount++
this.heap.incrementGeneration()
const currentHeap = this.heap.snapshot()
const diff = ManagedHeap.diff(this.previousHeap, currentHeap)
const prevVars = this.steps.length > 0 ? this.steps[this.steps.length - 1].variables : {}
const currentVars = this.serializeVariables(env.getAll())
diff.changedVars = Object.keys(currentVars).filter(k => JSON.stringify(currentVars[k]) !== JSON.stringify(prevVars[k]))
this.steps.push({
line,
column,
nodeType,
variables: currentVars,
heap: currentHeap,
callStack: [...this.callStack],
stdout: [...this.stdout],
diff,
})
this.previousHeap = currentHeap
}
private serializeVariables(vars: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(vars)) {
if (value && typeof value === 'object' && value.__isFunction) {
result[key] = '[Function]'
} else if (value && typeof value === 'object' && value.__isClass) {
result[key] = `[Class ${value.name}]`
} else if (value && typeof value === 'object' && value.__heapId) {
result[key] = `Ref<${this.heap.get(value.__heapId)?.type || 'Object'}#${value.__heapId}>`
} else if (Array.isArray(value)) {
result[key] = value
} else {
result[key] = value
}
}
return result
}
private executeBlock(statements: any[], env: Environment): any {
for (const stmt of statements) {
const result = this.executeStatement(stmt, env)
if (result && (result.__return || result.__break || result.__continue)) {
return result
}
}
return undefined
}
private executeStatement(node: any, env: Environment): any {
if (!node) return undefined
switch (node.type) {
case 'VariableDeclaration': return this.execVarDecl(node, env)
case 'ExpressionStatement':
this.captureStep(node.loc.start.line, node.loc.start.column, 'ExpressionStatement', env)
this.evaluate(node.expression, env)
return undefined
case 'FunctionDeclaration':
env.define(node.id.name, { __isFunction: true, name: node.id.name, params: node.params, body: node.body, closure: env })
return undefined
case 'ClassDeclaration': return this.execClass(node, env)
case 'IfStatement': return this.execIf(node, env)
case 'WhileStatement': return this.execWhile(node, env)
case 'DoWhileStatement': return this.execDoWhile(node, env)
case 'ForStatement': return this.execFor(node, env)
case 'ForOfStatement': return this.execForOf(node, env)
case 'ForInStatement': return this.execForIn(node, env)
case 'ReturnStatement':
this.captureStep(node.loc.start.line, node.loc.start.column, 'ReturnStatement', env)
return { __return: true, value: node.argument ? this.evaluate(node.argument, env) : undefined }
case 'BreakStatement': return { __break: true }
case 'ContinueStatement': return { __continue: true }
case 'ThrowStatement':
this.captureStep(node.loc.start.line, node.loc.start.column, 'ThrowStatement', env)
throw this.evaluate(node.argument, env)
case 'TryStatement': return this.execTry(node, env)
case 'SwitchStatement': return this.execSwitch(node, env)
case 'BlockStatement': return this.executeBlock(node.body, env.extend())
default: return undefined
}
}
private execVarDecl(node: any, env: Environment): void {
const kind = node.kind as 'var' | 'let' | 'const'
for (const decl of node.declarations) {
const value = decl.init ? this.evaluate(decl.init, env) : undefined
if (decl.id.type === 'Identifier') {
env.define(decl.id.name, value, kind)
} else if (decl.id.type === 'ObjectPattern') {
this.destructureObject(decl.id, value, env, kind)
} else if (decl.id.type === 'ArrayPattern') {
this.destructureArray(decl.id, value, env, kind)
}
}
this.captureStep(node.loc.start.line, node.loc.start.column, 'VariableDeclaration', env)
}
private destructureObject(pattern: any, value: any, env: Environment, kind: 'var' | 'let' | 'const'): void {
for (const prop of pattern.properties) {
const key = prop.key.type === 'Identifier' ? prop.key.name : prop.key.value
const target = prop.value.type === 'Identifier' ? prop.value.name : key
const resolved = value && value.__heapId ? this.heap.get(value.__heapId)?.properties[key] : value?.[key]
env.define(target, resolved ?? prop.value.right?.value, kind)
}
}
private destructureArray(pattern: any, value: any, env: Environment, kind: 'var' | 'let' | 'const'): void {
const arr = Array.isArray(value) ? value : []
pattern.elements.forEach((el: any, i: number) => {
if (!el) return
if (el.type === 'Identifier') {
env.define(el.name, arr[i], kind)
} else if (el.type === 'RestElement' && el.argument.type === 'Identifier') {
env.define(el.argument.name, arr.slice(i), kind)
}
})
}
private execClass(node: any, env: Environment): void {
const methods: Record<string, any> = {}
let ctor: any = null
let superClass: any = null
if (node.superClass) {
superClass = this.evaluate(node.superClass, env)
}
for (const item of node.body.body) {
if (item.type === 'MethodDefinition') {
if (item.kind === 'constructor') {
ctor = item.value
} else {
methods[item.key.name || item.key.value] = {
params: item.value.params,
body: item.value.body,
kind: item.kind,
}
}
}
}
env.define(node.id.name, {
__isClass: true,
name: node.id.name,
constructor: ctor,
methods,
superClass,
}, 'let')
}
private execIf(node: any, env: Environment): any {
this.captureStep(node.loc.start.line, node.loc.start.column, 'IfStatement', env)
const test = this.evaluate(node.test, env)
if (test) {
return node.consequent.type === 'BlockStatement'
? this.executeBlock(node.consequent.body, env.extend())
: this.executeStatement(node.consequent, env)
} else if (node.alternate) {
return node.alternate.type === 'BlockStatement'
? this.executeBlock(node.alternate.body, env.extend())
: this.executeStatement(node.alternate, env)
}
return undefined
}
private execWhile(node: any, env: Environment): any {
while (true) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'WhileStatement', env)
if (!this.evaluate(node.test, env)) break
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, env.extend())
: this.executeStatement(node.body, env)
if (result?.__return) return result
if (result?.__break) break
}
return undefined
}
private execDoWhile(node: any, env: Environment): any {
do {
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, env.extend())
: this.executeStatement(node.body, env)
if (result?.__return) return result
if (result?.__break) break
this.captureStep(node.loc.start.line, node.loc.start.column, 'DoWhileStatement', env)
} while (this.evaluate(node.test, env))
return undefined
}
private execFor(node: any, env: Environment): any {
const forEnv = env.extend()
if (node.init) {
if (node.init.type === 'VariableDeclaration') this.execVarDecl(node.init, forEnv)
else this.evaluate(node.init, forEnv)
}
while (true) {
if (node.test) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'ForStatement', forEnv)
if (!this.evaluate(node.test, forEnv)) break
}
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, forEnv.extend())
: this.executeStatement(node.body, forEnv)
if (result?.__return) return result
if (result?.__break) break
if (node.update) this.evaluate(node.update, forEnv)
}
return undefined
}
private execForOf(node: any, env: Environment): any {
const forEnv = env.extend()
const iterable = this.evaluate(node.right, env)
const arr = Array.isArray(iterable) ? iterable : []
for (const item of arr) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'ForOfStatement', forEnv)
if (node.left.type === 'VariableDeclaration') {
forEnv.define(node.left.declarations[0].id.name, item, node.left.kind)
}
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, forEnv.extend())
: this.executeStatement(node.body, forEnv)
if (result?.__return) return result
if (result?.__break) break
}
return undefined
}
private execForIn(node: any, env: Environment): any {
const forEnv = env.extend()
const obj = this.evaluate(node.right, env)
const keys = obj && obj.__heapId
? Object.keys(this.heap.get(obj.__heapId)?.properties || {}).filter(k => !k.startsWith('__'))
: Object.keys(obj || {})
for (const key of keys) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'ForInStatement', forEnv)
if (node.left.type === 'VariableDeclaration') {
forEnv.define(node.left.declarations[0].id.name, key, node.left.kind)
}
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, forEnv.extend())
: this.executeStatement(node.body, forEnv)
if (result?.__return) return result
if (result?.__break) break
}
return undefined
}
private execTry(node: any, env: Environment): any {
try {
return this.executeBlock(node.block.body, env.extend())
} catch (e: any) {
if (node.handler) {
const catchEnv = env.extend()
if (node.handler.param) {
catchEnv.define(node.handler.param.name, e, 'let')
}
return this.executeBlock(node.handler.body.body, catchEnv)
}
} finally {
if (node.finalizer) {
this.executeBlock(node.finalizer.body, env.extend())
}
}
return undefined
}
private execSwitch(node: any, env: Environment): any {
const discriminant = this.evaluate(node.discriminant, env)
let matched = false
for (const c of node.cases) {
if (!matched && c.test !== null) {
if (this.evaluate(c.test, env) !== discriminant) continue
}
matched = true
for (const stmt of c.consequent) {
const result = this.executeStatement(stmt, env)
if (result?.__break) return undefined
if (result?.__return) return result
}
}
return undefined
}
What does this code do?
This first section establishes the interpreter's core loop:
- run() parses the source with Acorn (using ecmaVersion: 'latest' and locations: true for line/column tracking), creates a global environment, and walks the AST body.
- captureStep() fires at every meaningful statement. It snapshots the heap, diffs it against the previous snapshot, identifies which variables changed, and pushes a complete ExecutionStep record.
- executeStatement() is the statement dispatcher. It pattern-matches on node.type and routes to a dedicated handler for each statement kind.
- Each loop handler (execWhile, execFor, etc.) properly handles __break, __continue, and __return signals to support control flow across nested structures.
- Save src/interpreter/interpreter.ts.
You will see TypeScript errors in the terminal because the expression methods (evaluate, evalCall, etc.) don't exist yet. The next two sections complete the file.
Add the expression evaluator
The expression evaluator is the counterpart to the statement dispatcher. Where statements control flow, expressions produce values. Every operator, property access, array literal, and template string passes through the evaluate() method.
- In src/interpreter/interpreter.ts, add the following methods directly below the execSwitch method (still inside the Interpreter class):
private evaluate(node: any, env: Environment): any {
if (!node) return undefined
switch (node.type) {
case 'Literal': return node.value
case 'Identifier': return node.name === 'undefined' ? undefined : node.name === 'null' ? null : env.get(node.name)
case 'BinaryExpression': return this.evalBinary(node, env)
case 'LogicalExpression': return this.evalLogical(node, env)
case 'UnaryExpression': return this.evalUnary(node, env)
case 'AssignmentExpression': return this.evalAssign(node, env)
case 'UpdateExpression': return this.evalUpdate(node, env)
case 'CallExpression': return this.evalCall(node, env)
case 'MemberExpression': return this.evalMember(node, env)
case 'NewExpression': return this.evalNew(node, env)
case 'ObjectExpression': return this.evalObject(node, env)
case 'ArrayExpression': return node.elements.map((el: any) => {
if (!el) return undefined
if (el.type === 'SpreadElement') {
const arr = this.evaluate(el.argument, env)
return Array.isArray(arr) ? arr : [arr]
}
return this.evaluate(el, env)
}).flat()
case 'ArrowFunctionExpression':
case 'FunctionExpression':
return { __isFunction: true, name: node.id?.name || 'anonymous', params: node.params, body: node.body, closure: env }
case 'ConditionalExpression':
return this.evaluate(node.test, env) ? this.evaluate(node.consequent, env) : this.evaluate(node.alternate, env)
case 'TemplateLiteral': return this.evalTemplate(node, env)
case 'SpreadElement': return this.evaluate(node.argument, env)
case 'ThisExpression': return env.get('this')
case 'SequenceExpression':
let last: any
for (const expr of node.expressions) last = this.evaluate(expr, env)
return last
default: return undefined
}
}
private evalBinary(node: any, env: Environment): any {
const l = this.evaluate(node.left, env)
const r = this.evaluate(node.right, env)
switch (node.operator) {
case '+': return l + r
case '-': return l - r
case '*': return l * r
case '/': return l / r
case '%': return l % r
case '**': return l ** r
case '===': return l === r
case '!==': return l !== r
case '==': return l == r
case '!=': return l != r
case '<': return l < r
case '>': return l > r
case '<=': return l <= r
case '>=': return l >= r
case '??': return l ?? r
case '&': return l & r
case '|': return l | r
case '^': return l ^ r
case '<<': return l << r
case '>>': return l >> r
case '>>>': return l >>> r
case 'instanceof': return false
default: return undefined
}
}
private evalLogical(node: any, env: Environment): any {
const l = this.evaluate(node.left, env)
if (node.operator === '&&') return l && this.evaluate(node.right, env)
if (node.operator === '||') return l || this.evaluate(node.right, env)
if (node.operator === '??') return l ?? this.evaluate(node.right, env)
return undefined
}
private evalUnary(node: any, env: Environment): any {
const arg = this.evaluate(node.argument, env)
switch (node.operator) {
case '!': return !arg
case '-': return -arg
case '+': return +arg
case 'typeof': return typeof arg
case 'void': return undefined
default: return undefined
}
}
private evalAssign(node: any, env: Environment): any {
let value = this.evaluate(node.right, env)
if (node.operator !== '=') {
const current = node.left.type === 'Identifier' ? env.get(node.left.name) : this.evalMember(node.left, env)
switch (node.operator) {
case '+=': value = current + value; break
case '-=': value = current - value; break
case '*=': value = current * value; break
case '/=': value = current / value; break
case '%=': value = current % value; break
case '**=': value = current ** value; break
}
}
if (node.left.type === 'Identifier') {
env.set(node.left.name, value)
} else if (node.left.type === 'MemberExpression') {
const obj = this.evaluate(node.left.object, env)
const prop = node.left.computed ? this.evaluate(node.left.property, env) : node.left.property.name
if (obj && obj.__heapId) {
this.heap.setProperty(obj.__heapId, prop, value)
} else if (Array.isArray(obj)) {
obj[prop] = value
}
}
return value
}
private evalUpdate(node: any, env: Environment): any {
const current = this.evaluate(node.argument, env)
const newVal = node.operator === '++' ? current + 1 : current - 1
if (node.argument.type === 'Identifier') env.set(node.argument.name, newVal)
return node.prefix ? newVal : current
}
private evalMember(node: any, env: Environment): any {
const obj = this.evaluate(node.object, env)
const prop = node.computed ? this.evaluate(node.property, env) : node.property.name
if (obj && obj.__heapId) {
const heapObj = this.heap.get(obj.__heapId)
return heapObj?.properties[prop]
}
if (Array.isArray(obj)) {
if (prop === 'length') return obj.length
return obj[prop]
}
if (typeof obj === 'string') {
if (prop === 'length') return obj.length
return (obj as any)[prop]
}
if (obj !== null && obj !== undefined) {
return obj[prop]
}
return undefined
}
private evalObject(node: any, env: Environment): any {
const properties: Record<string, any> = {}
for (const prop of node.properties) {
if (prop.type === 'SpreadElement') {
const spread = this.evaluate(prop.argument, env)
if (spread && spread.__heapId) {
const heapObj = this.heap.get(spread.__heapId)
if (heapObj) Object.assign(properties, heapObj.properties)
}
} else {
const key = prop.key.type === 'Identifier' ? prop.key.name : this.evaluate(prop.key, env)
if (prop.value.type === 'FunctionExpression' || prop.value.type === 'ArrowFunctionExpression') {
properties[key] = { __isFunction: true, name: key, params: prop.value.params, body: prop.value.body, closure: env }
} else {
properties[key] = this.evaluate(prop.value, env)
}
}
}
const { ref } = this.heap.allocate('Object', properties)
return ref
}
private evalTemplate(node: any, env: Environment): string {
let result = ''
for (let i = 0; i < node.quasis.length; i++) {
result += node.quasis[i].value.cooked || ''
if (i < node.expressions.length) {
result += this.stringify(this.evaluate(node.expressions[i], env))
}
}
return result
}
private stringify(value: any): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (value?.__heapId) {
const obj = this.heap.get(value.__heapId)
return obj ? `[${obj.type}#${obj.id}]` : '[Object]'
}
if (Array.isArray(value)) return `[${value.map(v => this.stringify(v)).join(', ')}]`
return String(value)
}
What does this code do?
The evaluate() method is the expression dispatcher. It handles every value-producing node type in the AST:
- evalBinary covers 20+ operators including exponentiation (**), nullish coalescing (??), and all bitwise operators.
- evalAssign handles compound assignment (+=, -=, etc.) and member assignment to heap objects.
- evalMember resolves property access on heap objects, arrays, and strings, supporting both dot notation and computed ([]) access.
- Arrow functions and function expressions capture the current env as their closure property. This is how closures work: the function remembers the environment where it was defined.
- Save the file.
You will still see TypeScript errors because evalCall and evalNew are referenced in evaluate() but not yet defined. The final section adds them.
Add class instantiation, closures, and built-in methods
The evalCall method is the most complex piece of the interpreter. It handles regular function calls, method calls on heap objects, and all built-in methods (console, arrays, Math, Object). evalNew handles class instantiation with constructor execution.
- In src/interpreter/interpreter.ts, add the following methods below stringify (still inside the Interpreter class). Then close the class with a final }:
private evalCall(node: any, env: Environment): any {
if (node.callee.type === 'MemberExpression') {
const obj = this.evaluate(node.callee.object, env)
const method = node.callee.computed ? this.evaluate(node.callee.property, env) : node.callee.property.name
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'console' && method === 'log') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
this.stdout.push(args.map(a => this.stringify(a)).join(' '))
return undefined
}
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'Math') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
return (Math as any)[method]?.(...args)
}
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'Object') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
if (method === 'keys') {
const target = args[0]
if (target && target.__heapId) {
return Object.keys(this.heap.get(target.__heapId)?.properties || {}).filter(k => !k.startsWith('__'))
}
return Object.keys(target || {})
}
if (method === 'values') {
const target = args[0]
if (target && target.__heapId) {
const props = this.heap.get(target.__heapId)?.properties || {}
return Object.entries(props).filter(([k]) => !k.startsWith('__')).map(([, v]) => v)
}
}
return undefined
}
if (Array.isArray(obj)) {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
switch (method) {
case 'push': return obj.push(...args)
case 'pop': return obj.pop()
case 'shift': return obj.shift()
case 'unshift': return obj.unshift(...args)
case 'splice': return obj.splice(...args)
case 'slice': return obj.slice(...args)
case 'indexOf': return obj.indexOf(args[0])
case 'includes': return obj.includes(args[0])
case 'join': return obj.join(args[0] ?? ',')
case 'reverse': return obj.reverse()
case 'sort': return obj.sort((a: any, b: any) => a - b)
case 'map': {
const fn = args[0]
if (fn?.__isFunction) return obj.map((item, idx) => this.callFunction(fn, [item, idx], env))
return obj
}
case 'filter': {
const fn = args[0]
if (fn?.__isFunction) return obj.filter((item, idx) => this.callFunction(fn, [item, idx], env))
return obj
}
case 'reduce': {
const fn = args[0]
let acc = args.length > 1 ? args[1] : obj[0]
const startIdx = args.length > 1 ? 0 : 1
if (fn?.__isFunction) {
for (let i = startIdx; i < obj.length; i++) {
acc = this.callFunction(fn, [acc, obj[i], i], env)
}
}
return acc
}
case 'forEach': {
const fn = args[0]
if (fn?.__isFunction) obj.forEach((item: any, idx: number) => this.callFunction(fn, [item, idx], env))
return undefined
}
case 'find': {
const fn = args[0]
if (fn?.__isFunction) return obj.find((item: any, idx: number) => this.callFunction(fn, [item, idx], env))
return undefined
}
case 'concat': return obj.concat(...args)
default: return undefined
}
}
if (typeof obj === 'string') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
return (obj as any)[method]?.(...args)
}
if (obj && obj.__heapId) {
const heapObj = this.heap.get(obj.__heapId)
if (heapObj?.properties.__methods?.[method]) {
const methodDef = heapObj.properties.__methods[method]
const methodEnv = env.extend()
methodEnv.define('this', obj, 'const')
methodDef.params.forEach((p: any, i: number) => {
const argVal = node.arguments[i] ? this.evaluate(node.arguments[i], env) : undefined
methodEnv.define(p.name, argVal, 'let')
})
this.callStack.push({ name: `${heapObj.type}.${method}`, line: node.loc.start.line, scopeId: methodEnv.id })
const result = this.executeBlock(methodDef.body.body, methodEnv)
this.callStack.pop()
return result?.__return ? result.value : undefined
}
}
return undefined
}
const callee = this.evaluate(node.callee, env)
const args = node.arguments.map((a: any) => {
if (a.type === 'SpreadElement') {
const spread = this.evaluate(a.argument, env)
return Array.isArray(spread) ? spread : [spread]
}
return this.evaluate(a, env)
}).flat()
if (callee?.__isFunction) {
return this.callFunction(callee, args, env, node.callee.name || callee.name, node.loc.start.line)
}
return undefined
}
private callFunction(fn: any, args: any[], callerEnv: Environment, name?: string, line?: number): any {
const funcEnv = new Environment(fn.closure)
fn.params.forEach((p: any, i: number) => {
if (p.type === 'Identifier') {
funcEnv.define(p.name, args[i], 'let')
} else if (p.type === 'RestElement' && p.argument.type === 'Identifier') {
funcEnv.define(p.argument.name, args.slice(i), 'let')
} else if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') {
funcEnv.define(p.left.name, args[i] !== undefined ? args[i] : this.evaluate(p.right, funcEnv), 'let')
}
})
this.callStack.push({ name: name || 'anonymous', line: line || 0, scopeId: funcEnv.id })
let result: any
if (fn.body.type === 'BlockStatement') {
result = this.executeBlock(fn.body.body, funcEnv)
} else {
result = { __return: true, value: this.evaluate(fn.body, funcEnv) }
}
this.callStack.pop()
return result?.__return ? result.value : undefined
}
private evalNew(node: any, env: Environment): any {
const classDef = this.evaluate(node.callee, env)
const args = node.arguments.map((a: any) => this.evaluate(a, env))
if (classDef?.__isClass) {
const { ref, obj } = this.heap.allocate(classDef.name, { __methods: classDef.methods })
if (classDef.constructor) {
const ctorEnv = env.extend()
ctorEnv.define('this', ref, 'const')
if (classDef.superClass) {
ctorEnv.define('super', classDef.superClass, 'const')
}
classDef.constructor.params.forEach((p: any, i: number) => {
if (p.type === 'Identifier') ctorEnv.define(p.name, args[i], 'let')
else if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') {
ctorEnv.define(p.left.name, args[i] !== undefined ? args[i] : this.evaluate(p.right, ctorEnv), 'let')
}
})
this.callStack.push({ name: `new ${classDef.name}`, line: node.loc.start.line, scopeId: ctorEnv.id })
this.executeBlock(classDef.constructor.body.body, ctorEnv)
this.callStack.pop()
}
return ref
}
if (classDef?.__isFunction) {
const { ref } = this.heap.allocate(node.callee.name || 'Object')
const ctorEnv = new Environment(classDef.closure)
ctorEnv.define('this', ref, 'const')
classDef.params.forEach((p: any, i: number) => {
if (p.type === 'Identifier') ctorEnv.define(p.name, args[i], 'let')
})
this.callStack.push({ name: `new ${node.callee.name || 'Object'}`, line: node.loc.start.line, scopeId: ctorEnv.id })
this.executeBlock(classDef.body.body, ctorEnv)
this.callStack.pop()
return ref
}
return undefined
}
}
What does this code do?
This section completes the interpreter with its most complex methods:
- evalCall routes method calls to built-in handlers for console.log, Math methods, Object utilities, and 15 array methods. For heap objects with __methods, it creates a method environment with this bound correctly.
- callFunction creates a new Environment whose parent is the function's closure (not the caller's environment). This is proper lexical scoping. It also handles rest parameters and default values.
- evalNew allocates a heap object, attaches the class's methods, runs the constructor with this bound to the new object, and returns the heap reference. Constructor property assignments (like this.value = value) write directly to the heap via evalAssign.
- Save src/interpreter/interpreter.ts.
- Check the terminal where your dev server is running. You should see no TypeScript compilation errors for interpreter.ts.
Seeing TypeScript errors?
- Make sure all three sections are inside the same Interpreter class body. The class opens with export class Interpreter { in section 1 and closes with a single } at the very end of section 3.
- Check that the imports at the top include Environment, ManagedHeap, and all types from ./types. A missing import causes cascading errors.
- Verify that acorn is installed. Run npm list acorn in your terminal. If missing, the previous step's dependencies were not installed correctly.
Help me debug TypeScript errors in my interpreter.ts file
✔️ Awesome, I've got everything!
Great. Double check you have saved src/interpreter/interpreter.ts and your terminal shows no compilation errors.
ⓧ I'd like to double check the full code
Here is the complete src/interpreter/interpreter.ts file with all three sections combined:
import * as acorn from 'acorn'
import { Environment } from './environment'
import { ManagedHeap } from './heap'
import { ExecutionStep, HeapObject, InterpreterConfig, InterpreterResult, StackFrame, StepDiff } from './types'
const DEFAULT_CONFIG: InterpreterConfig = {
maxSteps: 2000,
timeoutMs: 10000,
breakpoints: [],
watchExpressions: [],
}
export class Interpreter {
private steps: ExecutionStep[] = []
private heap: ManagedHeap = new ManagedHeap()
private callStack: StackFrame[] = []
private stdout: string[] = []
private stepCount = 0
private config: InterpreterConfig = DEFAULT_CONFIG
private startTime = 0
private previousHeap: Map<number, HeapObject> | null = null
run(code: string, config: Partial<InterpreterConfig> = {}): InterpreterResult {
this.steps = []
this.heap = new ManagedHeap()
this.callStack = [{ name: 'global', line: 1, scopeId: 'global' }]
this.stdout = []
this.stepCount = 0
this.config = { ...DEFAULT_CONFIG, ...config }
this.startTime = Date.now()
this.previousHeap = null
try {
const ast = acorn.parse(code, {
ecmaVersion: 'latest',
sourceType: 'script',
locations: true,
}) as any
const globalEnv = new Environment()
this.executeBlock(ast.body, globalEnv)
return {
steps: this.steps,
error: null,
executionTimeMs: Date.now() - this.startTime,
}
} catch (e: any) {
return {
steps: this.steps,
error: e.message,
executionTimeMs: Date.now() - this.startTime,
}
}
}
private checkLimits(): void {
if (this.stepCount >= this.config.maxSteps) {
throw new Error(`Execution limit reached (${this.config.maxSteps} steps)`)
}
if (Date.now() - this.startTime > this.config.timeoutMs) {
throw new Error(`Execution timeout (${this.config.timeoutMs}ms)`)
}
}
private captureStep(line: number, column: number, nodeType: string, env: Environment): void {
this.checkLimits()
this.stepCount++
this.heap.incrementGeneration()
const currentHeap = this.heap.snapshot()
const diff = ManagedHeap.diff(this.previousHeap, currentHeap)
const prevVars = this.steps.length > 0 ? this.steps[this.steps.length - 1].variables : {}
const currentVars = this.serializeVariables(env.getAll())
diff.changedVars = Object.keys(currentVars).filter(k => JSON.stringify(currentVars[k]) !== JSON.stringify(prevVars[k]))
this.steps.push({
line,
column,
nodeType,
variables: currentVars,
heap: currentHeap,
callStack: [...this.callStack],
stdout: [...this.stdout],
diff,
})
this.previousHeap = currentHeap
}
private serializeVariables(vars: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(vars)) {
if (value && typeof value === 'object' && value.__isFunction) {
result[key] = '[Function]'
} else if (value && typeof value === 'object' && value.__isClass) {
result[key] = `[Class ${value.name}]`
} else if (value && typeof value === 'object' && value.__heapId) {
result[key] = `Ref<${this.heap.get(value.__heapId)?.type || 'Object'}#${value.__heapId}>`
} else if (Array.isArray(value)) {
result[key] = value
} else {
result[key] = value
}
}
return result
}
private executeBlock(statements: any[], env: Environment): any {
for (const stmt of statements) {
const result = this.executeStatement(stmt, env)
if (result && (result.__return || result.__break || result.__continue)) {
return result
}
}
return undefined
}
private executeStatement(node: any, env: Environment): any {
if (!node) return undefined
switch (node.type) {
case 'VariableDeclaration': return this.execVarDecl(node, env)
case 'ExpressionStatement':
this.captureStep(node.loc.start.line, node.loc.start.column, 'ExpressionStatement', env)
this.evaluate(node.expression, env)
return undefined
case 'FunctionDeclaration':
env.define(node.id.name, { __isFunction: true, name: node.id.name, params: node.params, body: node.body, closure: env })
return undefined
case 'ClassDeclaration': return this.execClass(node, env)
case 'IfStatement': return this.execIf(node, env)
case 'WhileStatement': return this.execWhile(node, env)
case 'DoWhileStatement': return this.execDoWhile(node, env)
case 'ForStatement': return this.execFor(node, env)
case 'ForOfStatement': return this.execForOf(node, env)
case 'ForInStatement': return this.execForIn(node, env)
case 'ReturnStatement':
this.captureStep(node.loc.start.line, node.loc.start.column, 'ReturnStatement', env)
return { __return: true, value: node.argument ? this.evaluate(node.argument, env) : undefined }
case 'BreakStatement': return { __break: true }
case 'ContinueStatement': return { __continue: true }
case 'ThrowStatement':
this.captureStep(node.loc.start.line, node.loc.start.column, 'ThrowStatement', env)
throw this.evaluate(node.argument, env)
case 'TryStatement': return this.execTry(node, env)
case 'SwitchStatement': return this.execSwitch(node, env)
case 'BlockStatement': return this.executeBlock(node.body, env.extend())
default: return undefined
}
}
private execVarDecl(node: any, env: Environment): void {
const kind = node.kind as 'var' | 'let' | 'const'
for (const decl of node.declarations) {
const value = decl.init ? this.evaluate(decl.init, env) : undefined
if (decl.id.type === 'Identifier') {
env.define(decl.id.name, value, kind)
} else if (decl.id.type === 'ObjectPattern') {
this.destructureObject(decl.id, value, env, kind)
} else if (decl.id.type === 'ArrayPattern') {
this.destructureArray(decl.id, value, env, kind)
}
}
this.captureStep(node.loc.start.line, node.loc.start.column, 'VariableDeclaration', env)
}
private destructureObject(pattern: any, value: any, env: Environment, kind: 'var' | 'let' | 'const'): void {
for (const prop of pattern.properties) {
const key = prop.key.type === 'Identifier' ? prop.key.name : prop.key.value
const target = prop.value.type === 'Identifier' ? prop.value.name : key
const resolved = value && value.__heapId ? this.heap.get(value.__heapId)?.properties[key] : value?.[key]
env.define(target, resolved ?? prop.value.right?.value, kind)
}
}
private destructureArray(pattern: any, value: any, env: Environment, kind: 'var' | 'let' | 'const'): void {
const arr = Array.isArray(value) ? value : []
pattern.elements.forEach((el: any, i: number) => {
if (!el) return
if (el.type === 'Identifier') {
env.define(el.name, arr[i], kind)
} else if (el.type === 'RestElement' && el.argument.type === 'Identifier') {
env.define(el.argument.name, arr.slice(i), kind)
}
})
}
private execClass(node: any, env: Environment): void {
const methods: Record<string, any> = {}
let ctor: any = null
let superClass: any = null
if (node.superClass) {
superClass = this.evaluate(node.superClass, env)
}
for (const item of node.body.body) {
if (item.type === 'MethodDefinition') {
if (item.kind === 'constructor') {
ctor = item.value
} else {
methods[item.key.name || item.key.value] = {
params: item.value.params,
body: item.value.body,
kind: item.kind,
}
}
}
}
env.define(node.id.name, {
__isClass: true,
name: node.id.name,
constructor: ctor,
methods,
superClass,
}, 'let')
}
private execIf(node: any, env: Environment): any {
this.captureStep(node.loc.start.line, node.loc.start.column, 'IfStatement', env)
const test = this.evaluate(node.test, env)
if (test) {
return node.consequent.type === 'BlockStatement'
? this.executeBlock(node.consequent.body, env.extend())
: this.executeStatement(node.consequent, env)
} else if (node.alternate) {
return node.alternate.type === 'BlockStatement'
? this.executeBlock(node.alternate.body, env.extend())
: this.executeStatement(node.alternate, env)
}
return undefined
}
private execWhile(node: any, env: Environment): any {
while (true) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'WhileStatement', env)
if (!this.evaluate(node.test, env)) break
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, env.extend())
: this.executeStatement(node.body, env)
if (result?.__return) return result
if (result?.__break) break
}
return undefined
}
private execDoWhile(node: any, env: Environment): any {
do {
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, env.extend())
: this.executeStatement(node.body, env)
if (result?.__return) return result
if (result?.__break) break
this.captureStep(node.loc.start.line, node.loc.start.column, 'DoWhileStatement', env)
} while (this.evaluate(node.test, env))
return undefined
}
private execFor(node: any, env: Environment): any {
const forEnv = env.extend()
if (node.init) {
if (node.init.type === 'VariableDeclaration') this.execVarDecl(node.init, forEnv)
else this.evaluate(node.init, forEnv)
}
while (true) {
if (node.test) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'ForStatement', forEnv)
if (!this.evaluate(node.test, forEnv)) break
}
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, forEnv.extend())
: this.executeStatement(node.body, forEnv)
if (result?.__return) return result
if (result?.__break) break
if (node.update) this.evaluate(node.update, forEnv)
}
return undefined
}
private execForOf(node: any, env: Environment): any {
const forEnv = env.extend()
const iterable = this.evaluate(node.right, env)
const arr = Array.isArray(iterable) ? iterable : []
for (const item of arr) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'ForOfStatement', forEnv)
if (node.left.type === 'VariableDeclaration') {
forEnv.define(node.left.declarations[0].id.name, item, node.left.kind)
}
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, forEnv.extend())
: this.executeStatement(node.body, forEnv)
if (result?.__return) return result
if (result?.__break) break
}
return undefined
}
private execForIn(node: any, env: Environment): any {
const forEnv = env.extend()
const obj = this.evaluate(node.right, env)
const keys = obj && obj.__heapId
? Object.keys(this.heap.get(obj.__heapId)?.properties || {}).filter(k => !k.startsWith('__'))
: Object.keys(obj || {})
for (const key of keys) {
this.captureStep(node.loc.start.line, node.loc.start.column, 'ForInStatement', forEnv)
if (node.left.type === 'VariableDeclaration') {
forEnv.define(node.left.declarations[0].id.name, key, node.left.kind)
}
const result = node.body.type === 'BlockStatement'
? this.executeBlock(node.body.body, forEnv.extend())
: this.executeStatement(node.body, forEnv)
if (result?.__return) return result
if (result?.__break) break
}
return undefined
}
private execTry(node: any, env: Environment): any {
try {
return this.executeBlock(node.block.body, env.extend())
} catch (e: any) {
if (node.handler) {
const catchEnv = env.extend()
if (node.handler.param) {
catchEnv.define(node.handler.param.name, e, 'let')
}
return this.executeBlock(node.handler.body.body, catchEnv)
}
} finally {
if (node.finalizer) {
this.executeBlock(node.finalizer.body, env.extend())
}
}
return undefined
}
private execSwitch(node: any, env: Environment): any {
const discriminant = this.evaluate(node.discriminant, env)
let matched = false
for (const c of node.cases) {
if (!matched && c.test !== null) {
if (this.evaluate(c.test, env) !== discriminant) continue
}
matched = true
for (const stmt of c.consequent) {
const result = this.executeStatement(stmt, env)
if (result?.__break) return undefined
if (result?.__return) return result
}
}
return undefined
}
private evaluate(node: any, env: Environment): any {
if (!node) return undefined
switch (node.type) {
case 'Literal': return node.value
case 'Identifier': return node.name === 'undefined' ? undefined : node.name === 'null' ? null : env.get(node.name)
case 'BinaryExpression': return this.evalBinary(node, env)
case 'LogicalExpression': return this.evalLogical(node, env)
case 'UnaryExpression': return this.evalUnary(node, env)
case 'AssignmentExpression': return this.evalAssign(node, env)
case 'UpdateExpression': return this.evalUpdate(node, env)
case 'CallExpression': return this.evalCall(node, env)
case 'MemberExpression': return this.evalMember(node, env)
case 'NewExpression': return this.evalNew(node, env)
case 'ObjectExpression': return this.evalObject(node, env)
case 'ArrayExpression': return node.elements.map((el: any) => {
if (!el) return undefined
if (el.type === 'SpreadElement') {
const arr = this.evaluate(el.argument, env)
return Array.isArray(arr) ? arr : [arr]
}
return this.evaluate(el, env)
}).flat()
case 'ArrowFunctionExpression':
case 'FunctionExpression':
return { __isFunction: true, name: node.id?.name || 'anonymous', params: node.params, body: node.body, closure: env }
case 'ConditionalExpression':
return this.evaluate(node.test, env) ? this.evaluate(node.consequent, env) : this.evaluate(node.alternate, env)
case 'TemplateLiteral': return this.evalTemplate(node, env)
case 'SpreadElement': return this.evaluate(node.argument, env)
case 'ThisExpression': return env.get('this')
case 'SequenceExpression':
let last: any
for (const expr of node.expressions) last = this.evaluate(expr, env)
return last
default: return undefined
}
}
private evalBinary(node: any, env: Environment): any {
const l = this.evaluate(node.left, env)
const r = this.evaluate(node.right, env)
switch (node.operator) {
case '+': return l + r
case '-': return l - r
case '*': return l * r
case '/': return l / r
case '%': return l % r
case '**': return l ** r
case '===': return l === r
case '!==': return l !== r
case '==': return l == r
case '!=': return l != r
case '<': return l < r
case '>': return l > r
case '<=': return l <= r
case '>=': return l >= r
case '??': return l ?? r
case '&': return l & r
case '|': return l | r
case '^': return l ^ r
case '<<': return l << r
case '>>': return l >> r
case '>>>': return l >>> r
case 'instanceof': return false
default: return undefined
}
}
private evalLogical(node: any, env: Environment): any {
const l = this.evaluate(node.left, env)
if (node.operator === '&&') return l && this.evaluate(node.right, env)
if (node.operator === '||') return l || this.evaluate(node.right, env)
if (node.operator === '??') return l ?? this.evaluate(node.right, env)
return undefined
}
private evalUnary(node: any, env: Environment): any {
const arg = this.evaluate(node.argument, env)
switch (node.operator) {
case '!': return !arg
case '-': return -arg
case '+': return +arg
case 'typeof': return typeof arg
case 'void': return undefined
default: return undefined
}
}
private evalAssign(node: any, env: Environment): any {
let value = this.evaluate(node.right, env)
if (node.operator !== '=') {
const current = node.left.type === 'Identifier' ? env.get(node.left.name) : this.evalMember(node.left, env)
switch (node.operator) {
case '+=': value = current + value; break
case '-=': value = current - value; break
case '*=': value = current * value; break
case '/=': value = current / value; break
case '%=': value = current % value; break
case '**=': value = current ** value; break
}
}
if (node.left.type === 'Identifier') {
env.set(node.left.name, value)
} else if (node.left.type === 'MemberExpression') {
const obj = this.evaluate(node.left.object, env)
const prop = node.left.computed ? this.evaluate(node.left.property, env) : node.left.property.name
if (obj && obj.__heapId) {
this.heap.setProperty(obj.__heapId, prop, value)
} else if (Array.isArray(obj)) {
obj[prop] = value
}
}
return value
}
private evalUpdate(node: any, env: Environment): any {
const current = this.evaluate(node.argument, env)
const newVal = node.operator === '++' ? current + 1 : current - 1
if (node.argument.type === 'Identifier') env.set(node.argument.name, newVal)
return node.prefix ? newVal : current
}
private evalCall(node: any, env: Environment): any {
if (node.callee.type === 'MemberExpression') {
const obj = this.evaluate(node.callee.object, env)
const method = node.callee.computed ? this.evaluate(node.callee.property, env) : node.callee.property.name
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'console' && method === 'log') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
this.stdout.push(args.map(a => this.stringify(a)).join(' '))
return undefined
}
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'Math') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
return (Math as any)[method]?.(...args)
}
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'Object') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
if (method === 'keys') {
const target = args[0]
if (target && target.__heapId) {
return Object.keys(this.heap.get(target.__heapId)?.properties || {}).filter(k => !k.startsWith('__'))
}
return Object.keys(target || {})
}
if (method === 'values') {
const target = args[0]
if (target && target.__heapId) {
const props = this.heap.get(target.__heapId)?.properties || {}
return Object.entries(props).filter(([k]) => !k.startsWith('__')).map(([, v]) => v)
}
}
return undefined
}
if (Array.isArray(obj)) {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
switch (method) {
case 'push': return obj.push(...args)
case 'pop': return obj.pop()
case 'shift': return obj.shift()
case 'unshift': return obj.unshift(...args)
case 'splice': return obj.splice(...args)
case 'slice': return obj.slice(...args)
case 'indexOf': return obj.indexOf(args[0])
case 'includes': return obj.includes(args[0])
case 'join': return obj.join(args[0] ?? ',')
case 'reverse': return obj.reverse()
case 'sort': return obj.sort((a: any, b: any) => a - b)
case 'map': {
const fn = args[0]
if (fn?.__isFunction) return obj.map((item, idx) => this.callFunction(fn, [item, idx], env))
return obj
}
case 'filter': {
const fn = args[0]
if (fn?.__isFunction) return obj.filter((item, idx) => this.callFunction(fn, [item, idx], env))
return obj
}
case 'reduce': {
const fn = args[0]
let acc = args.length > 1 ? args[1] : obj[0]
const startIdx = args.length > 1 ? 0 : 1
if (fn?.__isFunction) {
for (let i = startIdx; i < obj.length; i++) {
acc = this.callFunction(fn, [acc, obj[i], i], env)
}
}
return acc
}
case 'forEach': {
const fn = args[0]
if (fn?.__isFunction) obj.forEach((item: any, idx: number) => this.callFunction(fn, [item, idx], env))
return undefined
}
case 'find': {
const fn = args[0]
if (fn?.__isFunction) return obj.find((item: any, idx: number) => this.callFunction(fn, [item, idx], env))
return undefined
}
case 'concat': return obj.concat(...args)
default: return undefined
}
}
if (typeof obj === 'string') {
const args = node.arguments.map((a: any) => this.evaluate(a, env))
return (obj as any)[method]?.(...args)
}
if (obj && obj.__heapId) {
const heapObj = this.heap.get(obj.__heapId)
if (heapObj?.properties.__methods?.[method]) {
const methodDef = heapObj.properties.__methods[method]
const methodEnv = env.extend()
methodEnv.define('this', obj, 'const')
methodDef.params.forEach((p: any, i: number) => {
const argVal = node.arguments[i] ? this.evaluate(node.arguments[i], env) : undefined
methodEnv.define(p.name, argVal, 'let')
})
this.callStack.push({ name: `${heapObj.type}.${method}`, line: node.loc.start.line, scopeId: methodEnv.id })
const result = this.executeBlock(methodDef.body.body, methodEnv)
this.callStack.pop()
return result?.__return ? result.value : undefined
}
}
return undefined
}
const callee = this.evaluate(node.callee, env)
const args = node.arguments.map((a: any) => {
if (a.type === 'SpreadElement') {
const spread = this.evaluate(a.argument, env)
return Array.isArray(spread) ? spread : [spread]
}
return this.evaluate(a, env)
}).flat()
if (callee?.__isFunction) {
return this.callFunction(callee, args, env, node.callee.name || callee.name, node.loc.start.line)
}
return undefined
}
private callFunction(fn: any, args: any[], callerEnv: Environment, name?: string, line?: number): any {
const funcEnv = new Environment(fn.closure)
fn.params.forEach((p: any, i: number) => {
if (p.type === 'Identifier') {
funcEnv.define(p.name, args[i], 'let')
} else if (p.type === 'RestElement' && p.argument.type === 'Identifier') {
funcEnv.define(p.argument.name, args.slice(i), 'let')
} else if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') {
funcEnv.define(p.left.name, args[i] !== undefined ? args[i] : this.evaluate(p.right, funcEnv), 'let')
}
})
this.callStack.push({ name: name || 'anonymous', line: line || 0, scopeId: funcEnv.id })
let result: any
if (fn.body.type === 'BlockStatement') {
result = this.executeBlock(fn.body.body, funcEnv)
} else {
result = { __return: true, value: this.evaluate(fn.body, funcEnv) }
}
this.callStack.pop()
return result?.__return ? result.value : undefined
}
private evalMember(node: any, env: Environment): any {
const obj = this.evaluate(node.object, env)
const prop = node.computed ? this.evaluate(node.property, env) : node.property.name
if (obj && obj.__heapId) {
const heapObj = this.heap.get(obj.__heapId)
return heapObj?.properties[prop]
}
if (Array.isArray(obj)) {
if (prop === 'length') return obj.length
return obj[prop]
}
if (typeof obj === 'string') {
if (prop === 'length') return obj.length
return (obj as any)[prop]
}
if (obj !== null && obj !== undefined) {
return obj[prop]
}
return undefined
}
private evalNew(node: any, env: Environment): any {
const classDef = this.evaluate(node.callee, env)
const args = node.arguments.map((a: any) => this.evaluate(a, env))
if (classDef?.__isClass) {
const { ref, obj } = this.heap.allocate(classDef.name, { __methods: classDef.methods })
if (classDef.constructor) {
const ctorEnv = env.extend()
ctorEnv.define('this', ref, 'const')
if (classDef.superClass) {
ctorEnv.define('super', classDef.superClass, 'const')
}
classDef.constructor.params.forEach((p: any, i: number) => {
if (p.type === 'Identifier') ctorEnv.define(p.name, args[i], 'let')
else if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') {
ctorEnv.define(p.left.name, args[i] !== undefined ? args[i] : this.evaluate(p.right, ctorEnv), 'let')
}
})
this.callStack.push({ name: `new ${classDef.name}`, line: node.loc.start.line, scopeId: ctorEnv.id })
this.executeBlock(classDef.constructor.body.body, ctorEnv)
this.callStack.pop()
}
return ref
}
if (classDef?.__isFunction) {
const { ref } = this.heap.allocate(node.callee.name || 'Object')
const ctorEnv = new Environment(classDef.closure)
ctorEnv.define('this', ref, 'const')
classDef.params.forEach((p: any, i: number) => {
if (p.type === 'Identifier') ctorEnv.define(p.name, args[i], 'let')
})
this.callStack.push({ name: `new ${node.callee.name || 'Object'}`, line: node.loc.start.line, scopeId: ctorEnv.id })
this.executeBlock(classDef.body.body, ctorEnv)
this.callStack.pop()
return ref
}
return undefined
}
private evalObject(node: any, env: Environment): any {
const properties: Record<string, any> = {}
for (const prop of node.properties) {
if (prop.type === 'SpreadElement') {
const spread = this.evaluate(prop.argument, env)
if (spread && spread.__heapId) {
const heapObj = this.heap.get(spread.__heapId)
if (heapObj) Object.assign(properties, heapObj.properties)
}
} else {
const key = prop.key.type === 'Identifier' ? prop.key.name : this.evaluate(prop.key, env)
if (prop.value.type === 'FunctionExpression' || prop.value.type === 'ArrowFunctionExpression') {
properties[key] = { __isFunction: true, name: key, params: prop.value.params, body: prop.value.body, closure: env }
} else {
properties[key] = this.evaluate(prop.value, env)
}
}
}
const { ref } = this.heap.allocate('Object', properties)
return ref
}
private evalTemplate(node: any, env: Environment): string {
let result = ''
for (let i = 0; i < node.quasis.length; i++) {
result += node.quasis[i].value.cooked || ''
if (i < node.expressions.length) {
result += this.stringify(this.evaluate(node.expressions[i], env))
}
}
return result
}
private stringify(value: any): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (value?.__heapId) {
const obj = this.heap.get(value.__heapId)
return obj ? `[${obj.type}#${obj.id}]` : '[Object]'
}
if (Array.isArray(value)) return `[${value.map(v => this.stringify(v)).join(', ')}]`
return String(value)
}
}
Your execution engine is complete. It can parse and interpret any JavaScript program that uses variables, functions, classes, loops, and all standard operators. Next up, you will sandbox this interpreter inside a Web Worker so it runs safely off the main thread.
Build the Shape Classifier and Layout Algorithms
Your interpreter now captures complete heap snapshots at every execution step. Each snapshot contains a map of allocated objects with their properties and references. The next challenge is figuring out what those objects represent.
In this step, you will build an intelligent shape classifier that examines object property patterns and assigns confidence scores to determine whether a group of heap objects forms a binary tree, linked list, doubly-linked list, or graph. You will also implement two layout algorithms that compute x/y positions for rendering those structures visually.
In this step, get ready to:
- Build a weighted heuristic shape classifier that auto-detects data structures from heap object property patterns.
- Implement the Reingold-Tilford tree layout algorithm for aesthetically balanced binary tree positioning.
- Implement a force-directed graph layout using spring-force simulation for arbitrary object graphs.
Create the weighted heuristic shape classifier
The classifier needs to look at each heap object's properties, score how likely it is to be part of a specific data structure type, and then group related objects into coherent structures. A simple boolean check would fail on ambiguous cases. Weighted scoring lets the system handle edge cases gracefully.
- Create a new file src/classifier/index.ts inside your code-visualizer project.
- Add the type definitions, main orchestration function, and reference graph utilities by pasting this code:
import { HeapObject } from '../interpreter/types'
export type StructureType = 'binary-tree' | 'linked-list' | 'doubly-linked-list' | 'graph' | 'array' | 'hash-map' | 'object'
export interface DetectedStructure {
type: StructureType
rootId: number
objects: HeapObject[]
confidence: number
}
// Main entry point: classifies all heap objects into detected structures
export function classifyHeap(heap: Map<number, HeapObject>, variables: Record<string, any>): DetectedStructure[] {
const structures: DetectedStructure[] = []
const claimed = new Set<number>()
const refs = buildReferenceGraph(heap)
heap.forEach((obj, id) => {
if (claimed.has(id)) return
const treeScore = scoreAsTree(obj, heap)
const listScore = scoreAsLinkedList(obj, heap)
const dllScore = scoreAsDoublyLinkedList(obj, heap)
const graphScore = scoreAsGraph(obj, heap)
const best = Math.max(treeScore, listScore, dllScore, graphScore)
if (best < 0.5) return
const isRoot = isRootNode(id, refs, heap)
if (!isRoot) return
if (best === treeScore && treeScore >= 0.5) {
const objects = collectTree(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'binary-tree', rootId: id, objects, confidence: treeScore })
} else if (best === dllScore && dllScore >= 0.5) {
const objects = collectLinkedList(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'doubly-linked-list', rootId: id, objects, confidence: dllScore })
} else if (best === listScore && listScore >= 0.5) {
const objects = collectLinkedList(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'linked-list', rootId: id, objects, confidence: listScore })
} else if (best === graphScore && graphScore >= 0.5) {
const objects = collectGraph(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'graph', rootId: id, objects, confidence: graphScore })
}
})
return structures.sort((a, b) => b.confidence - a.confidence)
}
// Builds a map of which objects are referenced by which other objects
function buildReferenceGraph(heap: Map<number, HeapObject>): Map<number, Set<number>> {
const refs = new Map<number, Set<number>>()
heap.forEach((obj, id) => {
for (const value of Object.values(obj.properties)) {
if (value && typeof value === 'object' && value.__heapId) {
if (!refs.has(value.__heapId)) refs.set(value.__heapId, new Set())
refs.get(value.__heapId)!.add(id)
}
if (Array.isArray(value)) {
for (const item of value) {
if (item && typeof item === 'object' && item.__heapId) {
if (!refs.has(item.__heapId)) refs.set(item.__heapId, new Set())
refs.get(item.__heapId)!.add(id)
}
}
}
}
})
return refs
}
// A root node has no parents of the same type pointing to it
function isRootNode(id: number, refs: Map<number, Set<number>>, heap: Map<number, HeapObject>): boolean {
const parents = refs.get(id)
if (!parents || parents.size === 0) return true
const obj = heap.get(id)
if (!obj) return false
for (const parentId of parents) {
const parent = heap.get(parentId)
if (parent && parent.type === obj.type) return false
}
return true
}
// Helper: get non-internal property names
function getProps(obj: HeapObject): string[] {
return Object.keys(obj.properties).filter(k => !k.startsWith('__'))
}
What does this code do?
The classifyHeap function iterates every object on the heap, runs all four scoring functions against it, and picks the highest-confidence match. It only considers objects that score above 0.5 (a coin-flip threshold).
The isRootNode check prevents the classifier from creating duplicate structures. If an object is already pointed to by another object of the same type, it is a child node and should be collected as part of its parent's structure rather than starting a new one.
You won't see a change yet. The file references scoring and collection functions that you will add next.
- Below the getProps function in the same file, add the scoring functions and collection traversals:
// Scores how likely an object is to be a binary tree node (0-1)
function scoreAsTree(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
if (props.includes('left') && props.includes('right')) score += 0.4
else return 0
if (props.includes('value') || props.includes('val') || props.includes('data') || props.includes('key')) score += 0.2
const left = obj.properties.left
const right = obj.properties.right
if ((left === null || (left?.__heapId && heap.has(left.__heapId))) &&
(right === null || (right?.__heapId && heap.has(right.__heapId)))) score += 0.3
if (left?.__heapId || right?.__heapId) score += 0.1
return score
}
// Scores how likely an object is to be a singly linked list node
function scoreAsLinkedList(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
if (!props.includes('next')) return 0
score += 0.4
if (props.includes('value') || props.includes('val') || props.includes('data')) score += 0.2
if (props.includes('left') || props.includes('right')) return 0
const next = obj.properties.next
if (next === null || (next?.__heapId && heap.has(next.__heapId))) score += 0.3
if (!props.includes('prev')) score += 0.1
return score
}
// Scores how likely an object is to be a doubly linked list node
function scoreAsDoublyLinkedList(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
if (!props.includes('next') || !props.includes('prev')) return 0
score += 0.5
if (props.includes('value') || props.includes('val') || props.includes('data')) score += 0.2
if (props.includes('left') || props.includes('right')) return 0
score += 0.3
return score
}
// Scores how likely an object is to be a graph node (adjacency list pattern)
function scoreAsGraph(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
const graphProps = ['neighbors', 'edges', 'adjacent', 'children', 'connections']
const hasGraphProp = props.some(p => graphProps.includes(p))
if (!hasGraphProp) return 0
score += 0.5
const graphProp = props.find(p => graphProps.includes(p))!
const value = obj.properties[graphProp]
if (Array.isArray(value)) score += 0.3
if (props.includes('value') || props.includes('id') || props.includes('label')) score += 0.2
return score
}
// Traverses a tree structure from a root, collecting all nodes
function collectTree(rootId: number, heap: Map<number, HeapObject>): HeapObject[] {
const result: HeapObject[] = []
const visited = new Set<number>()
function traverse(id: number) {
if (visited.has(id)) return
visited.add(id)
const obj = heap.get(id)
if (!obj) return
result.push(obj)
if (obj.properties.left?.__heapId) traverse(obj.properties.left.__heapId)
if (obj.properties.right?.__heapId) traverse(obj.properties.right.__heapId)
}
traverse(rootId)
return result
}
// Follows next pointers to collect a linked list
function collectLinkedList(headId: number, heap: Map<number, HeapObject>): HeapObject[] {
const result: HeapObject[] = []
const visited = new Set<number>()
let current: number | null = headId
while (current !== null && !visited.has(current)) {
visited.add(current)
const obj = heap.get(current)
if (!obj) break
result.push(obj)
current = obj.properties.next?.__heapId ?? null
}
return result
}
// BFS traversal to collect all connected graph nodes
function collectGraph(startId: number, heap: Map<number, HeapObject>): HeapObject[] {
const result: HeapObject[] = []
const visited = new Set<number>()
const queue = [startId]
while (queue.length > 0) {
const id = queue.shift()!
if (visited.has(id)) continue
visited.add(id)
const obj = heap.get(id)
if (!obj) continue
result.push(obj)
for (const value of Object.values(obj.properties)) {
if (value?.__heapId && !visited.has(value.__heapId)) queue.push(value.__heapId)
if (Array.isArray(value)) {
for (const item of value) {
if (item?.__heapId && !visited.has(item.__heapId)) queue.push(item.__heapId)
}
}
}
}
return result
}
How does weighted scoring work?
Each scorer returns a value between 0 and 1 by accumulating points for matching property patterns. For example, scoreAsTree awards 0.4 for having both left and right properties, 0.2 for a value field, 0.3 for valid child references, and 0.1 for having at least one non-null child.
Early returns (return 0) act as hard disqualifiers. A linked list node that also has left/right properties is immediately disqualified from the linked list classification because it is almost certainly a tree node.
- Save src/classifier/index.ts.
- Your dev server should still be running without errors. The classifier module is complete but not yet connected to the UI.
Seeing a red squiggly on the import?
Make sure the file path matches exactly: src/classifier/index.ts. The import references '../interpreter/types' which assumes the classifier folder is a sibling of the interpreter folder inside src/.
help me fix the import path for my classifier
✔️ Awesome, I've got everything!
Great. Double check you have saved src/classifier/index.ts before moving on.
ⓧ I'd like to double check the full code
import { HeapObject } from '../interpreter/types'
export type StructureType = 'binary-tree' | 'linked-list' | 'doubly-linked-list' | 'graph' | 'array' | 'hash-map' | 'object'
export interface DetectedStructure {
type: StructureType
rootId: number
objects: HeapObject[]
confidence: number
}
export function classifyHeap(heap: Map<number, HeapObject>, variables: Record<string, any>): DetectedStructure[] {
const structures: DetectedStructure[] = []
const claimed = new Set<number>()
const refs = buildReferenceGraph(heap)
heap.forEach((obj, id) => {
if (claimed.has(id)) return
const treeScore = scoreAsTree(obj, heap)
const listScore = scoreAsLinkedList(obj, heap)
const dllScore = scoreAsDoublyLinkedList(obj, heap)
const graphScore = scoreAsGraph(obj, heap)
const best = Math.max(treeScore, listScore, dllScore, graphScore)
if (best < 0.5) return
const isRoot = isRootNode(id, refs, heap)
if (!isRoot) return
if (best === treeScore && treeScore >= 0.5) {
const objects = collectTree(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'binary-tree', rootId: id, objects, confidence: treeScore })
} else if (best === dllScore && dllScore >= 0.5) {
const objects = collectLinkedList(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'doubly-linked-list', rootId: id, objects, confidence: dllScore })
} else if (best === listScore && listScore >= 0.5) {
const objects = collectLinkedList(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'linked-list', rootId: id, objects, confidence: listScore })
} else if (best === graphScore && graphScore >= 0.5) {
const objects = collectGraph(id, heap)
objects.forEach(o => claimed.add(o.id))
structures.push({ type: 'graph', rootId: id, objects, confidence: graphScore })
}
})
return structures.sort((a, b) => b.confidence - a.confidence)
}
function buildReferenceGraph(heap: Map<number, HeapObject>): Map<number, Set<number>> {
const refs = new Map<number, Set<number>>()
heap.forEach((obj, id) => {
for (const value of Object.values(obj.properties)) {
if (value && typeof value === 'object' && value.__heapId) {
if (!refs.has(value.__heapId)) refs.set(value.__heapId, new Set())
refs.get(value.__heapId)!.add(id)
}
if (Array.isArray(value)) {
for (const item of value) {
if (item && typeof item === 'object' && item.__heapId) {
if (!refs.has(item.__heapId)) refs.set(item.__heapId, new Set())
refs.get(item.__heapId)!.add(id)
}
}
}
}
})
return refs
}
function isRootNode(id: number, refs: Map<number, Set<number>>, heap: Map<number, HeapObject>): boolean {
const parents = refs.get(id)
if (!parents || parents.size === 0) return true
const obj = heap.get(id)
if (!obj) return false
for (const parentId of parents) {
const parent = heap.get(parentId)
if (parent && parent.type === obj.type) return false
}
return true
}
function getProps(obj: HeapObject): string[] {
return Object.keys(obj.properties).filter(k => !k.startsWith('__'))
}
function scoreAsTree(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
if (props.includes('left') && props.includes('right')) score += 0.4
else return 0
if (props.includes('value') || props.includes('val') || props.includes('data') || props.includes('key')) score += 0.2
const left = obj.properties.left
const right = obj.properties.right
if ((left === null || (left?.__heapId && heap.has(left.__heapId))) &&
(right === null || (right?.__heapId && heap.has(right.__heapId)))) score += 0.3
if (left?.__heapId || right?.__heapId) score += 0.1
return score
}
function scoreAsLinkedList(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
if (!props.includes('next')) return 0
score += 0.4
if (props.includes('value') || props.includes('val') || props.includes('data')) score += 0.2
if (props.includes('left') || props.includes('right')) return 0
const next = obj.properties.next
if (next === null || (next?.__heapId && heap.has(next.__heapId))) score += 0.3
if (!props.includes('prev')) score += 0.1
return score
}
function scoreAsDoublyLinkedList(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
if (!props.includes('next') || !props.includes('prev')) return 0
score += 0.5
if (props.includes('value') || props.includes('val') || props.includes('data')) score += 0.2
if (props.includes('left') || props.includes('right')) return 0
score += 0.3
return score
}
function scoreAsGraph(obj: HeapObject, heap: Map<number, HeapObject>): number {
const props = getProps(obj)
let score = 0
const graphProps = ['neighbors', 'edges', 'adjacent', 'children', 'connections']
const hasGraphProp = props.some(p => graphProps.includes(p))
if (!hasGraphProp) return 0
score += 0.5
const graphProp = props.find(p => graphProps.includes(p))!
const value = obj.properties[graphProp]
if (Array.isArray(value)) score += 0.3
if (props.includes('value') || props.includes('id') || props.includes('label')) score += 0.2
return score
}
function collectTree(rootId: number, heap: Map<number, HeapObject>): HeapObject[] {
const result: HeapObject[] = []
const visited = new Set<number>()
function traverse(id: number) {
if (visited.has(id)) return
visited.add(id)
const obj = heap.get(id)
if (!obj) return
result.push(obj)
if (obj.properties.left?.__heapId) traverse(obj.properties.left.__heapId)
if (obj.properties.right?.__heapId) traverse(obj.properties.right.__heapId)
}
traverse(rootId)
return result
}
function collectLinkedList(headId: number, heap: Map<number, HeapObject>): HeapObject[] {
const result: HeapObject[] = []
const visited = new Set<number>()
let current: number | null = headId
while (current !== null && !visited.has(current)) {
visited.add(current)
const obj = heap.get(current)
if (!obj) break
result.push(obj)
current = obj.properties.next?.__heapId ?? null
}
return result
}
function collectGraph(startId: number, heap: Map<number, HeapObject>): HeapObject[] {
const result: HeapObject[] = []
const visited = new Set<number>()
const queue = [startId]
while (queue.length > 0) {
const id = queue.shift()!
if (visited.has(id)) continue
visited.add(id)
const obj = heap.get(id)
if (!obj) continue
result.push(obj)
for (const value of Object.values(obj.properties)) {
if (value?.__heapId && !visited.has(value.__heapId)) queue.push(value.__heapId)
if (Array.isArray(value)) {
for (const item of value) {
if (item?.__heapId && !visited.has(item.__heapId)) queue.push(item.__heapId)
}
}
}
}
return result
}
Implement the Reingold-Tilford tree layout
The Reingold-Tilford algorithm produces aesthetically balanced tree diagrams where nodes at the same depth share a y-level, left subtrees stay left, and each subtree is centered over its children. This is far superior to a naive depth-first x-assignment which creates lopsided, overlapping drawings.
Why Reingold-Tilford instead of a simple recursive layout?
A naive approach assigns x-positions by inorder traversal index. This works for perfectly balanced trees but produces ugly, overlapping results for unbalanced ones. Reingold-Tilford computes initial positions bottom-up, then applies a final pass to shift nodes so the tree is compact and symmetric.
- Create a new file src/layouts/reingold-tilford.ts and paste the full tree layout implementation:
import { HeapObject } from '../interpreter/types'
export interface LayoutNode {
id: number
x: number
y: number
value: string
children: number[]
}
export interface LayoutEdge {
sourceId: number
targetId: number
label?: string
}
// Internal tree structure used during layout computation
interface TreeNode {
id: number
value: string
left: TreeNode | null
right: TreeNode | null
x: number
y: number
mod: number
}
// Computes aesthetically balanced x/y positions for a binary tree
export function reingoldTilford(rootId: number, heap: Map<number, HeapObject>): { nodes: LayoutNode[], edges: LayoutEdge[] } {
const tree = buildTree(rootId, heap, 0)
if (!tree) return { nodes: [], edges: [] }
computeInitialX(tree)
computeFinalX(tree, 0)
const nodes: LayoutNode[] = []
const edges: LayoutEdge[] = []
flatten(tree, nodes, edges)
return { nodes, edges }
}
// Recursively builds the internal tree from heap objects
function buildTree(id: number, heap: Map<number, HeapObject>, depth: number): TreeNode | null {
const obj = heap.get(id)
if (!obj) return null
const value = obj.properties.value ?? obj.properties.val ?? obj.properties.data ?? obj.properties.key ?? '?'
const leftRef = obj.properties.left
const rightRef = obj.properties.right
return {
id,
value: String(value),
left: leftRef?.__heapId ? buildTree(leftRef.__heapId, heap, depth + 1) : null,
right: rightRef?.__heapId ? buildTree(rightRef.__heapId, heap, depth + 1) : null,
x: 0,
y: depth * 80 + 40,
mod: 0,
}
}
// Bottom-up pass: assigns initial x based on children positions
function computeInitialX(node: TreeNode): void {
if (node.left) computeInitialX(node.left)
if (node.right) computeInitialX(node.right)
if (!node.left && !node.right) {
node.x = 0
} else if (node.left && !node.right) {
node.x = node.left.x
} else if (!node.left && node.right) {
node.x = node.right.x
} else if (node.left && node.right) {
node.x = (node.left.x + node.right.x) / 2
const gap = node.right.x - node.left.x
if (gap < 60) {
const shift = (60 - gap) / 2
node.left.x -= shift
node.right.x += shift
node.x = (node.left.x + node.right.x) / 2
}
}
}
// Top-down pass: applies accumulated modifier offsets
function computeFinalX(node: TreeNode, modSum: number): void {
node.x += modSum
if (node.left) computeFinalX(node.left, modSum + node.mod)
if (node.right) computeFinalX(node.right, modSum + node.mod)
}
// Converts internal tree to flat arrays of positioned nodes and edges
function flatten(node: TreeNode, nodes: LayoutNode[], edges: LayoutEdge[]): void {
const children: number[] = []
if (node.left) { children.push(node.left.id); edges.push({ sourceId: node.id, targetId: node.left.id, label: 'L' }) }
if (node.right) { children.push(node.right.id); edges.push({ sourceId: node.id, targetId: node.right.id, label: 'R' }) }
nodes.push({ id: node.id, x: node.x, y: node.y, value: node.value, children })
if (node.left) flatten(node.left, nodes, edges)
if (node.right) flatten(node.right, nodes, edges)
}
What does the two-pass approach accomplish?
The bottom-up computeInitialX pass positions each node centered above its children. It also enforces a minimum gap of 60 pixels between siblings so nodes never overlap.
The top-down computeFinalX pass applies accumulated mod offsets. In a full Reingold-Tilford implementation, these modifiers shift entire subtrees to prevent overlap between non-sibling branches. The flatten function then converts the recursive tree structure into flat arrays that the renderer can consume.
- Save src/layouts/reingold-tilford.ts.
- You won't see a visual change yet. The renderer that draws these positioned nodes comes in a later step.
✔️ Awesome, I've got everything!
Great. Confirm you have saved the file before continuing.
ⓧ I'd like to double check the full code
import { HeapObject } from '../interpreter/types'
export interface LayoutNode {
id: number
x: number
y: number
value: string
children: number[]
}
export interface LayoutEdge {
sourceId: number
targetId: number
label?: string
}
interface TreeNode {
id: number
value: string
left: TreeNode | null
right: TreeNode | null
x: number
y: number
mod: number
}
export function reingoldTilford(rootId: number, heap: Map<number, HeapObject>): { nodes: LayoutNode[], edges: LayoutEdge[] } {
const tree = buildTree(rootId, heap, 0)
if (!tree) return { nodes: [], edges: [] }
computeInitialX(tree)
computeFinalX(tree, 0)
const nodes: LayoutNode[] = []
const edges: LayoutEdge[] = []
flatten(tree, nodes, edges)
return { nodes, edges }
}
function buildTree(id: number, heap: Map<number, HeapObject>, depth: number): TreeNode | null {
const obj = heap.get(id)
if (!obj) return null
const value = obj.properties.value ?? obj.properties.val ?? obj.properties.data ?? obj.properties.key ?? '?'
const leftRef = obj.properties.left
const rightRef = obj.properties.right
return {
id,
value: String(value),
left: leftRef?.__heapId ? buildTree(leftRef.__heapId, heap, depth + 1) : null,
right: rightRef?.__heapId ? buildTree(rightRef.__heapId, heap, depth + 1) : null,
x: 0,
y: depth * 80 + 40,
mod: 0,
}
}
function computeInitialX(node: TreeNode): void {
if (node.left) computeInitialX(node.left)
if (node.right) computeInitialX(node.right)
if (!node.left && !node.right) {
node.x = 0
} else if (node.left && !node.right) {
node.x = node.left.x
} else if (!node.left && node.right) {
node.x = node.right.x
} else if (node.left && node.right) {
node.x = (node.left.x + node.right.x) / 2
const gap = node.right.x - node.left.x
if (gap < 60) {
const shift = (60 - gap) / 2
node.left.x -= shift
node.right.x += shift
node.x = (node.left.x + node.right.x) / 2
}
}
}
function computeFinalX(node: TreeNode, modSum: number): void {
node.x += modSum
if (node.left) computeFinalX(node.left, modSum + node.mod)
if (node.right) computeFinalX(node.right, modSum + node.mod)
}
function flatten(node: TreeNode, nodes: LayoutNode[], edges: LayoutEdge[]): void {
const children: number[] = []
if (node.left) { children.push(node.left.id); edges.push({ sourceId: node.id, targetId: node.left.id, label: 'L' }) }
if (node.right) { children.push(node.right.id); edges.push({ sourceId: node.id, targetId: node.right.id, label: 'R' }) }
nodes.push({ id: node.id, x: node.x, y: node.y, value: node.value, children })
if (node.left) flatten(node.left, nodes, edges)
if (node.right) flatten(node.right, nodes, edges)
}
Implement the force-directed graph layout
Trees have a natural hierarchical structure. Arbitrary graphs do not. For graph structures (adjacency lists, networks), a force-directed layout simulates physical forces: nodes repel each other like charged particles, and edges act like springs pulling connected nodes together. After enough iterations, the system converges to a stable, readable arrangement.
- Create a new file src/layouts/force-directed.ts and paste the spring simulation implementation:
import { HeapObject } from '../interpreter/types'
export interface ForceNode {
id: number
x: number
y: number
vx: number
vy: number
label: string
}
export interface ForceEdge {
source: number
target: number
label: string
}
// Runs a spring-force simulation to compute stable graph positions
export function forceDirectedLayout(
objects: HeapObject[],
heap: Map<number, HeapObject>,
iterations: number = 100
): { nodes: ForceNode[], edges: ForceEdge[] } {
// Initialize nodes in a circle to avoid starting overlap
const nodes: ForceNode[] = objects.map((obj, i) => ({
id: obj.id,
x: Math.cos(2 * Math.PI * i / objects.length) * 150 + 200,
y: Math.sin(2 * Math.PI * i / objects.length) * 150 + 200,
vx: 0,
vy: 0,
label: String(obj.properties.value ?? obj.properties.id ?? obj.properties.label ?? obj.id),
}))
// Build edges from heap references between objects in this structure
const edges: ForceEdge[] = []
const nodeIds = new Set(objects.map(o => o.id))
for (const obj of objects) {
for (const [key, value] of Object.entries(obj.properties)) {
if (key.startsWith('__')) continue
if (value?.__heapId && nodeIds.has(value.__heapId)) {
edges.push({ source: obj.id, target: value.__heapId, label: key })
}
if (Array.isArray(value)) {
value.forEach((item, idx) => {
if (item?.__heapId && nodeIds.has(item.__heapId)) {
edges.push({ source: obj.id, target: item.__heapId, label: `${key}[${idx}]` })
}
})
}
}
}
// Physics constants for the simulation
const repulsion = 5000
const attraction = 0.01
const damping = 0.9
const minDist = 50
// Iterative force simulation
for (let iter = 0; iter < iterations; iter++) {
// Coulomb's law: all nodes repel each other
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x
const dy = nodes[j].y - nodes[i].y
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), minDist)
const force = repulsion / (dist * dist)
const fx = (dx / dist) * force
const fy = (dy / dist) * force
nodes[i].vx -= fx
nodes[i].vy -= fy
nodes[j].vx += fx
nodes[j].vy += fy
}
}
// Hooke's law: edges attract connected nodes
for (const edge of edges) {
const source = nodes.find(n => n.id === edge.source)
const target = nodes.find(n => n.id === edge.target)
if (!source || !target) continue
const dx = target.x - source.x
const dy = target.y - source.y
const dist = Math.sqrt(dx * dx + dy * dy)
const force = dist * attraction
const fx = (dx / dist) * force
const fy = (dy / dist) * force
source.vx += fx
source.vy += fy
target.vx -= fx
target.vy -= fy
}
// Apply velocity with damping to prevent oscillation
for (const node of nodes) {
node.vx *= damping
node.vy *= damping
node.x += node.vx
node.y += node.vy
}
}
// Normalize positions so nothing is negative
const minX = Math.min(...nodes.map(n => n.x))
const minY = Math.min(...nodes.map(n => n.y))
for (const node of nodes) {
node.x -= minX - 50
node.y -= minY - 50
}
return { nodes, edges }
}
How does the force simulation converge?
Each iteration applies three forces: Coulomb repulsion pushes all node pairs apart (inversely proportional to distance squared), Hooke attraction pulls connected nodes together (proportional to edge length), and damping (0.9 multiplier) gradually reduces velocity each frame.
After 100 iterations with these constants, most graph structures settle into a stable arrangement where connected nodes cluster together and unconnected groups separate. The final normalization pass shifts all positions so the top-left corner starts at (50, 50).
- Save src/layouts/force-directed.ts.
- Check that your dev server at http://localhost:5173 still loads without errors in the browser console.
Dev server showing a compile error?
Make sure both layout files are in the src/layouts/ directory (not inside src/interpreter/ or src/classifier/). The import '../interpreter/types' assumes layouts is a sibling folder of interpreter inside src/.
help me fix a compile error in my layout files
✔️ Awesome, I've got everything!
Great. Confirm both layout files are saved before moving on.
ⓧ I'd like to double check the full code
import { HeapObject } from '../interpreter/types'
export interface ForceNode {
id: number
x: number
y: number
vx: number
vy: number
label: string
}
export interface ForceEdge {
source: number
target: number
label: string
}
export function forceDirectedLayout(
objects: HeapObject[],
heap: Map<number, HeapObject>,
iterations: number = 100
): { nodes: ForceNode[], edges: ForceEdge[] } {
const nodes: ForceNode[] = objects.map((obj, i) => ({
id: obj.id,
x: Math.cos(2 * Math.PI * i / objects.length) * 150 + 200,
y: Math.sin(2 * Math.PI * i / objects.length) * 150 + 200,
vx: 0,
vy: 0,
label: String(obj.properties.value ?? obj.properties.id ?? obj.properties.label ?? obj.id),
}))
const edges: ForceEdge[] = []
const nodeIds = new Set(objects.map(o => o.id))
for (const obj of objects) {
for (const [key, value] of Object.entries(obj.properties)) {
if (key.startsWith('__')) continue
if (value?.__heapId && nodeIds.has(value.__heapId)) {
edges.push({ source: obj.id, target: value.__heapId, label: key })
}
if (Array.isArray(value)) {
value.forEach((item, idx) => {
if (item?.__heapId && nodeIds.has(item.__heapId)) {
edges.push({ source: obj.id, target: item.__heapId, label: `${key}[${idx}]` })
}
})
}
}
}
const repulsion = 5000
const attraction = 0.01
const damping = 0.9
const minDist = 50
for (let iter = 0; iter < iterations; iter++) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x
const dy = nodes[j].y - nodes[i].y
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), minDist)
const force = repulsion / (dist * dist)
const fx = (dx / dist) * force
const fy = (dy / dist) * force
nodes[i].vx -= fx
nodes[i].vy -= fy
nodes[j].vx += fx
nodes[j].vy += fy
}
}
for (const edge of edges) {
const source = nodes.find(n => n.id === edge.source)
const target = nodes.find(n => n.id === edge.target)
if (!source || !target) continue
const dx = target.x - source.x
const dy = target.y - source.y
const dist = Math.sqrt(dx * dx + dy * dy)
const force = dist * attraction
const fx = (dx / dist) * force
const fy = (dy / dist) * force
source.vx += fx
source.vy += fy
target.vx -= fx
target.vy -= fy
}
for (const node of nodes) {
node.vx *= damping
node.vy *= damping
node.x += node.vx
node.y += node.vy
}
}
const minX = Math.min(...nodes.map(n => n.x))
const minY = Math.min(...nodes.map(n => n.y))
for (const node of nodes) {
node.x -= minX - 50
node.y -= minY - 50
}
return { nodes, edges }
}
Your classifier can now identify binary trees, linked lists, doubly-linked lists, and graphs from raw heap objects. Your layout algorithms can compute aesthetic x/y positions for trees and graphs. Next up, you will build the React hooks and Web Worker integration that connect the interpreter to the UI.
Wire Up the React Hooks and App Shell
Your interpreter can execute JavaScript, your classifier can detect data structures, and your layout algorithms can position nodes. But none of these modules are accessible to a user yet.
In this step, you'll wire everything into a React application. You'll create hooks that manage interpreter execution and keyboard shortcuts, then build the full App shell with a code editor, state inspector, visualization area, and playback timeline.
In this step, get ready to:
- Create the useInterpreter hook for managing execution state.
- Create the useKeyboardControls hook for timeline navigation.
- Build the App component with examples, inspector, and timeline.
Create the useInterpreter hook
The interpreter runs synchronously and can block the UI on complex code. Wrapping it in a React hook with setTimeout(0) defers execution to the next event loop tick, keeping the interface responsive while the interpreter processes.
- Create a new file src/hooks/useInterpreter.ts and add the following code:
import { useState, useCallback, useRef } from 'react'
import { Interpreter } from '../interpreter/interpreter'
import { ExecutionStep, InterpreterConfig, InterpreterResult } from '../interpreter/types'
interface UseInterpreterReturn {
steps: ExecutionStep[]
error: string | null
isRunning: boolean
executionTime: number
run: (code: string, config?: Partial<InterpreterConfig>) => void
cancel: () => void
}
export function useInterpreter(): UseInterpreterReturn {
const [steps, setSteps] = useState<ExecutionStep[]>([])
const [error, setError] = useState<string | null>(null)
const [isRunning, setIsRunning] = useState(false)
const [executionTime, setExecutionTime] = useState(0)
const interpreterRef = useRef<Interpreter | null>(null)
const run = useCallback((code: string, config?: Partial<InterpreterConfig>) => {
setIsRunning(true)
setError(null)
setTimeout(() => {
const interpreter = new Interpreter()
interpreterRef.current = interpreter
const result: InterpreterResult = interpreter.run(code, config)
setSteps(result.steps)
setError(result.error)
setExecutionTime(result.executionTimeMs)
setIsRunning(false)
}, 0)
}, [])
const cancel = useCallback(() => {
setIsRunning(false)
}, [])
return { steps, error, isRunning, executionTime, run, cancel }
}
What does this code do?
This hook wraps your Interpreter class in a React-friendly interface.
- useState tracks the execution steps array, any error message, whether the interpreter is currently running, and how long execution took.
- useRef holds a reference to the Interpreter instance without triggering re-renders when it changes.
- setTimeout(0) defers the interpreter's synchronous work to the next event loop tick so React can render the "running" state before execution begins.
- useCallback ensures the run and cancel functions have stable references across re-renders.
- Save src/hooks/useInterpreter.ts.
- You won't see a change in the browser yet. The App component will use this hook in the next substep.
Seeing a TypeScript error?
Make sure the import path '../interpreter/interpreter' matches your file structure. The interpreter.ts file should be at src/interpreter/interpreter.ts.
help me fix my useInterpreter import paths
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { useState, useCallback, useRef } from 'react'
import { Interpreter } from '../interpreter/interpreter'
import { ExecutionStep, InterpreterConfig, InterpreterResult } from '../interpreter/types'
interface UseInterpreterReturn {
steps: ExecutionStep[]
error: string | null
isRunning: boolean
executionTime: number
run: (code: string, config?: Partial<InterpreterConfig>) => void
cancel: () => void
}
export function useInterpreter(): UseInterpreterReturn {
const [steps, setSteps] = useState<ExecutionStep[]>([])
const [error, setError] = useState<string | null>(null)
const [isRunning, setIsRunning] = useState(false)
const [executionTime, setExecutionTime] = useState(0)
const interpreterRef = useRef<Interpreter | null>(null)
const run = useCallback((code: string, config?: Partial<InterpreterConfig>) => {
setIsRunning(true)
setError(null)
setTimeout(() => {
const interpreter = new Interpreter()
interpreterRef.current = interpreter
const result: InterpreterResult = interpreter.run(code, config)
setSteps(result.steps)
setError(result.error)
setExecutionTime(result.executionTimeMs)
setIsRunning(false)
}, 0)
}, [])
const cancel = useCallback(() => {
setIsRunning(false)
}, [])
return { steps, error, isRunning, executionTime, run, cancel }
}
Create the useKeyboardControls hook
Professional debugging tools rely on keyboard shortcuts for efficient navigation. This hook registers global key listeners while being careful to skip events originating from text inputs and the code editor.
- Create a new file src/hooks/useKeyboardControls.ts and add the following code:
import { useEffect } from 'react'
interface KeyboardConfig {
onStepForward: () => void
onStepBackward: () => void
onPlayPause: () => void
onRun: () => void
onNextBreakpoint: () => void
onPrevBreakpoint: () => void
enabled: boolean
}
export function useKeyboardControls(config: KeyboardConfig): void {
useEffect(() => {
if (!config.enabled) return
const handler = (e: KeyboardEvent) => {
// Skip events from text inputs and the code editor
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
if ((e.target as HTMLElement)?.closest('.cm-editor')) return
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
if (e.shiftKey) config.onNextBreakpoint()
else config.onStepForward()
break
case 'ArrowLeft':
e.preventDefault()
if (e.shiftKey) config.onPrevBreakpoint()
else config.onStepBackward()
break
case ' ':
e.preventDefault()
config.onPlayPause()
break
case 'r':
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault()
config.onRun()
}
break
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [config])
}
What does this code do?
This hook adds global keyboard shortcuts for navigating the execution timeline:
- ArrowRight steps forward one execution step. With Shift held, it jumps to the next breakpoint.
- ArrowLeft steps backward. With Shift, it jumps to the previous breakpoint.
- Space toggles play/pause for auto-advancing through steps.
- r re-runs the code (only when Cmd/Ctrl is not held, so browser refresh still works).
The guard clauses at the top ensure these shortcuts don't fire when the learner is typing code or using text inputs.
- Save src/hooks/useKeyboardControls.ts.
- You won't see a change yet. The App component integrates these shortcuts next.
TypeScript complaining about the config parameter?
Make sure the KeyboardConfig interface is defined above the function in the same file. All callback properties must have the () => void return type.
help me fix my useKeyboardControls TypeScript errors
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { useEffect } from 'react'
interface KeyboardConfig {
onStepForward: () => void
onStepBackward: () => void
onPlayPause: () => void
onRun: () => void
onNextBreakpoint: () => void
onPrevBreakpoint: () => void
enabled: boolean
}
export function useKeyboardControls(config: KeyboardConfig): void {
useEffect(() => {
if (!config.enabled) return
const handler = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
if ((e.target as HTMLElement)?.closest('.cm-editor')) return
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
if (e.shiftKey) config.onNextBreakpoint()
else config.onStepForward()
break
case 'ArrowLeft':
e.preventDefault()
if (e.shiftKey) config.onPrevBreakpoint()
else config.onStepBackward()
break
case ' ':
e.preventDefault()
config.onPlayPause()
break
case 'r':
if (!e.metaKey && !e.ctrlKey) {
e.preventDefault()
config.onRun()
}
break
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [config])
}
Build the App component and styles
With the hooks ready, you can now compose the full application. The App component ties together the interpreter, keyboard controls, a set of example programs, and a three-panel layout with a playback timeline.
- Open src/App.tsx (already exists from the Vite scaffold) and replace its entire contents with the following imports and examples data:
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { useInterpreter } from './hooks/useInterpreter'
import { useKeyboardControls } from './hooks/useKeyboardControls'
import { ExecutionStep } from './interpreter/types'
// Pre-built example programs demonstrating different data structures
const EXAMPLES: Record<string, string> = {
'BST Insert + Delete': `class Node {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
function insert(root, value) {
if (root === null) return new Node(value);
if (value < root.value) root.left = insert(root.left, value);
else root.right = insert(root.right, value);
return root;
}
function findMin(node) {
while (node.left !== null) node = node.left;
return node;
}
function deleteNode(root, value) {
if (root === null) return null;
if (value < root.value) {
root.left = deleteNode(root.left, value);
} else if (value > root.value) {
root.right = deleteNode(root.right, value);
} else {
if (root.left === null) return root.right;
if (root.right === null) return root.left;
let successor = findMin(root.right);
root.value = successor.value;
root.right = deleteNode(root.right, successor.value);
}
return root;
}
let root = null;
root = insert(root, 50);
root = insert(root, 30);
root = insert(root, 70);
root = insert(root, 20);
root = insert(root, 40);
root = insert(root, 60);
root = insert(root, 80);
console.log("Deleting 50 (root)...");
root = deleteNode(root, 50);`,
'Linked List Reverse': `class ListNode {
constructor(value) {
this.value = value;
this.next = null;
}
}
function buildList(arr) {
let head = new ListNode(arr[0]);
let current = head;
for (let i = 1; i < arr.length; i++) {
current.next = new ListNode(arr[i]);
current = current.next;
}
return head;
}
function reverse(head) {
let prev = null;
let current = head;
while (current !== null) {
let next = current.next;
current.next = prev;
prev = current;
current = next;
}
return prev;
}
let list = buildList([1, 2, 3, 4, 5]);
console.log("Original list built");
let reversed = reverse(list);
console.log("List reversed");`,
'Merge Sort': `let arr = [38, 27, 43, 3, 9, 82, 10];
function mergeSort(array) {
if (array.length <= 1) return array;
let mid = Math.floor(array.length / 2);
let left = mergeSort(array.slice(0, mid));
let right = mergeSort(array.slice(mid));
return merge(left, right);
}
function merge(left, right) {
let result = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result.push(left[i]);
i++;
} else {
result.push(right[j]);
j++;
}
}
while (i < left.length) { result.push(left[i]); i++; }
while (j < right.length) { result.push(right[j]); j++; }
return result;
}
let sorted = mergeSort(arr);
console.log("Sorted:", sorted);`,
'BFS Graph': `class GraphNode {
constructor(id) {
this.id = id;
this.value = id;
this.neighbors = [];
}
}
let a = new GraphNode("A");
let b = new GraphNode("B");
let c = new GraphNode("C");
let d = new GraphNode("D");
let e = new GraphNode("E");
a.neighbors = [b, c];
b.neighbors = [a, d];
c.neighbors = [a, d, e];
d.neighbors = [b, c, e];
e.neighbors = [c, d];
let visited = [];
let queue = [a];
while (queue.length > 0) {
let node = queue.shift();
if (visited.indexOf(node.id) === -1) {
visited.push(node.id);
console.log("Visiting:", node.id);
for (let i = 0; i < node.neighbors.length; i++) {
if (visited.indexOf(node.neighbors[i].id) === -1) {
queue.push(node.neighbors[i]);
}
}
}
}`,
'Hash Map': `class HashMap {
constructor(size) {
this.size = size;
this.buckets = [];
for (let i = 0; i < size; i++) {
this.buckets.push([]);
}
}
}
function hash(key, size) {
let h = 0;
for (let i = 0; i < key.length; i++) {
h = (h + key.length * (i + 1)) % size;
}
return h;
}
function put(map, key, value) {
let idx = hash(key, map.size);
let bucket = map.buckets[idx];
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket[i][1] = value;
return;
}
}
bucket.push([key, value]);
}
let map = new HashMap(4);
put(map, "name", "Alice");
put(map, "age", "30");
put(map, "city", "NYC");
put(map, "job", "Engineer");
console.log("HashMap populated");`,
}
What does this code do?
The imports bring in your custom hooks and the ExecutionStep type so the component can work with execution data.
The EXAMPLES record stores five pre-built programs that exercise different classifier paths: binary tree operations, linked list reversal, recursive sorting, graph traversal, and hash map collisions. Each example is a complete JavaScript program the interpreter can execute.
- Now add the App component below the EXAMPLES constant in the same file:
function App() {
const { steps, error, isRunning, run } = useInterpreter()
const [code, setCode] = useState(EXAMPLES['BST Insert + Delete'])
const [currentStep, setCurrentStep] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [speed, setSpeed] = useState(400)
const [breakpoints, setBreakpoints] = useState<number[]>([])
const intervalRef = useRef<number | null>(null)
const handleRun = useCallback(() => {
run(code, { maxSteps: 2000, timeoutMs: 10000, breakpoints })
setCurrentStep(0)
setIsPlaying(false)
}, [code, run, breakpoints])
// Auto-advance through steps when playing
useEffect(() => {
if (isPlaying && steps.length > 0) {
intervalRef.current = window.setInterval(() => {
setCurrentStep(prev => {
if (prev >= steps.length - 1) { setIsPlaying(false); return prev }
return prev + 1
})
}, speed)
}
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [isPlaying, speed, steps.length])
// Wire up keyboard shortcuts
useKeyboardControls({
enabled: steps.length > 0,
onStepForward: () => setCurrentStep(s => Math.min(s + 1, steps.length - 1)),
onStepBackward: () => setCurrentStep(s => Math.max(s - 1, 0)),
onPlayPause: () => setIsPlaying(p => !p),
onRun: handleRun,
onNextBreakpoint: () => {
const next = steps.findIndex((s, i) => i > currentStep && breakpoints.includes(s.line))
if (next !== -1) setCurrentStep(next)
},
onPrevBreakpoint: () => {
for (let i = currentStep - 1; i >= 0; i--) {
if (breakpoints.includes(steps[i].line)) { setCurrentStep(i); break }
}
},
})
const currentStepData = steps.length > 0 ? steps[currentStep] : null
return (
<div className="app">
<header className="app-header">
<h1>Universal Code Visualizer</h1>
<div className="examples">
{Object.keys(EXAMPLES).map(name => (
<button key={name} className="example-btn" onClick={() => { setCode(EXAMPLES[name]); setCurrentStep(0); setIsPlaying(false) }}>
{name}
</button>
))}
</div>
<button className="run-btn" onClick={handleRun} disabled={isRunning}>
{isRunning ? '⏳ Running...' : '▶ Run'}
</button>
</header>
<main className="app-main">
<div className="left-panel">
<div className="editor-container">
{/* CodeMirror Editor renders here */}
<textarea
className="code-textarea"
value={code}
onChange={e => setCode(e.target.value)}
spellCheck={false}
/>
</div>
<div className="state-inspector">
{currentStepData && (
<>
<div className="inspector-section">
<h4>Variables</h4>
<div className="var-list">
{Object.entries(currentStepData.variables).map(([name, value]) => (
<div key={name} className={`var-item ${currentStepData.diff?.changedVars.includes(name) ? 'changed' : ''}`}>
<span className="var-name">{name}</span>
<span className="var-value">{JSON.stringify(value) ?? 'undefined'}</span>
</div>
))}
</div>
</div>
<div className="inspector-section">
<h4>Call Stack</h4>
{currentStepData.callStack.map((frame, i) => (
<div key={i} className="stack-frame">{frame.name} :L{frame.line}</div>
))}
</div>
{currentStepData.stdout.length > 0 && (
<div className="inspector-section">
<h4>Console</h4>
{currentStepData.stdout.map((line, i) => (
<div key={i} className="stdout-line">{line}</div>
))}
</div>
)}
</>
)}
</div>
</div>
<div className="right-panel">
<div className="viz-placeholder">
{/* VisualizationPanel renders detected structures here */}
{currentStepData ? (
<div className="viz-info">
Step {currentStep + 1}/{steps.length} | Line {currentStepData.line} | {currentStepData.nodeType}
{currentStepData.diff && (
<span className="diff-info">
{currentStepData.diff.created.length > 0 && ` +${currentStepData.diff.created.length}`}
{currentStepData.diff.modified.length > 0 && ` ~${currentStepData.diff.modified.length}`}
{currentStepData.diff.deleted.length > 0 && ` -${currentStepData.diff.deleted.length}`}
</span>
)}
</div>
) : (
<div className="viz-empty">Paste code and hit Run to visualize execution</div>
)}
</div>
</div>
</main>
{error && <div className="error-bar">{error}</div>}
{steps.length > 0 && (
<footer className="app-footer">
<div className="timeline">
<button className="timeline-btn" onClick={() => setCurrentStep(s => Math.max(0, s - 1))}>◀</button>
<button className="timeline-btn" onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '⏸' : '▶'}</button>
<button className="timeline-btn" onClick={() => setCurrentStep(s => Math.min(steps.length - 1, s + 1))}>▶▶</button>
<input type="range" min={0} max={steps.length - 1} value={currentStep} onChange={e => setCurrentStep(+e.target.value)} className="timeline-slider" />
<span className="timeline-info">Step {currentStep + 1}/{steps.length}</span>
<input type="range" min={50} max={3000} step={50} value={speed} onChange={e => setSpeed(+e.target.value)} className="speed-slider" />
<span className="speed-label">{speed}ms</span>
</div>
</footer>
)}
</div>
)
}
export default App
What does the App component do?
The component manages several pieces of state that work together:
- code holds the current editor content (defaulting to the BST example).
- currentStep tracks which execution step the user is viewing in the timeline.
- isPlaying and speed control the auto-advance interval that steps through execution automatically.
- The useEffect sets up a setInterval that advances the step counter at the configured speed, cleaning up the interval on pause or unmount.
- The layout uses three sections: a header with example buttons and a Run button, a main area split into editor+inspector and visualization, and a footer timeline with range sliders.
- Save src/App.tsx and refresh your browser.
- You should see the app structure rendering with example buttons across the header, a code textarea on the left, and a Run button. It will appear unstyled until you update the CSS next.
Seeing a blank page or console errors?
Check your browser console for import errors. The most common cause is a mismatch between the hook file paths and the import statements. Confirm that src/hooks/useInterpreter.ts and src/hooks/useKeyboardControls.ts both exist.
help me debug why my App.tsx isn't rendering
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.tsx before continuing to the styles.
ⓧ I'd like to double check the full code
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { useInterpreter } from './hooks/useInterpreter'
import { useKeyboardControls } from './hooks/useKeyboardControls'
import { ExecutionStep } from './interpreter/types'
const EXAMPLES: Record<string, string> = {
'BST Insert + Delete': `class Node {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
function insert(root, value) {
if (root === null) return new Node(value);
if (value < root.value) root.left = insert(root.left, value);
else root.right = insert(root.right, value);
return root;
}
function findMin(node) {
while (node.left !== null) node = node.left;
return node;
}
function deleteNode(root, value) {
if (root === null) return null;
if (value < root.value) {
root.left = deleteNode(root.left, value);
} else if (value > root.value) {
root.right = deleteNode(root.right, value);
} else {
if (root.left === null) return root.right;
if (root.right === null) return root.left;
let successor = findMin(root.right);
root.value = successor.value;
root.right = deleteNode(root.right, successor.value);
}
return root;
}
let root = null;
root = insert(root, 50);
root = insert(root, 30);
root = insert(root, 70);
root = insert(root, 20);
root = insert(root, 40);
root = insert(root, 60);
root = insert(root, 80);
console.log("Deleting 50 (root)...");
root = deleteNode(root, 50);`,
'Linked List Reverse': `class ListNode {
constructor(value) {
this.value = value;
this.next = null;
}
}
function buildList(arr) {
let head = new ListNode(arr[0]);
let current = head;
for (let i = 1; i < arr.length; i++) {
current.next = new ListNode(arr[i]);
current = current.next;
}
return head;
}
function reverse(head) {
let prev = null;
let current = head;
while (current !== null) {
let next = current.next;
current.next = prev;
prev = current;
current = next;
}
return prev;
}
let list = buildList([1, 2, 3, 4, 5]);
console.log("Original list built");
let reversed = reverse(list);
console.log("List reversed");`,
'Merge Sort': `let arr = [38, 27, 43, 3, 9, 82, 10];
function mergeSort(array) {
if (array.length <= 1) return array;
let mid = Math.floor(array.length / 2);
let left = mergeSort(array.slice(0, mid));
let right = mergeSort(array.slice(mid));
return merge(left, right);
}
function merge(left, right) {
let result = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result.push(left[i]);
i++;
} else {
result.push(right[j]);
j++;
}
}
while (i < left.length) { result.push(left[i]); i++; }
while (j < right.length) { result.push(right[j]); j++; }
return result;
}
let sorted = mergeSort(arr);
console.log("Sorted:", sorted);`,
'BFS Graph': `class GraphNode {
constructor(id) {
this.id = id;
this.value = id;
this.neighbors = [];
}
}
let a = new GraphNode("A");
let b = new GraphNode("B");
let c = new GraphNode("C");
let d = new GraphNode("D");
let e = new GraphNode("E");
a.neighbors = [b, c];
b.neighbors = [a, d];
c.neighbors = [a, d, e];
d.neighbors = [b, c, e];
e.neighbors = [c, d];
let visited = [];
let queue = [a];
while (queue.length > 0) {
let node = queue.shift();
if (visited.indexOf(node.id) === -1) {
visited.push(node.id);
console.log("Visiting:", node.id);
for (let i = 0; i < node.neighbors.length; i++) {
if (visited.indexOf(node.neighbors[i].id) === -1) {
queue.push(node.neighbors[i]);
}
}
}
}`,
'Hash Map': `class HashMap {
constructor(size) {
this.size = size;
this.buckets = [];
for (let i = 0; i < size; i++) {
this.buckets.push([]);
}
}
}
function hash(key, size) {
let h = 0;
for (let i = 0; i < key.length; i++) {
h = (h + key.length * (i + 1)) % size;
}
return h;
}
function put(map, key, value) {
let idx = hash(key, map.size);
let bucket = map.buckets[idx];
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket[i][1] = value;
return;
}
}
bucket.push([key, value]);
}
let map = new HashMap(4);
put(map, "name", "Alice");
put(map, "age", "30");
put(map, "city", "NYC");
put(map, "job", "Engineer");
console.log("HashMap populated");`,
}
function App() {
const { steps, error, isRunning, run } = useInterpreter()
const [code, setCode] = useState(EXAMPLES['BST Insert + Delete'])
const [currentStep, setCurrentStep] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [speed, setSpeed] = useState(400)
const [breakpoints, setBreakpoints] = useState<number[]>([])
const intervalRef = useRef<number | null>(null)
const handleRun = useCallback(() => {
run(code, { maxSteps: 2000, timeoutMs: 10000, breakpoints })
setCurrentStep(0)
setIsPlaying(false)
}, [code, run, breakpoints])
useEffect(() => {
if (isPlaying && steps.length > 0) {
intervalRef.current = window.setInterval(() => {
setCurrentStep(prev => {
if (prev >= steps.length - 1) { setIsPlaying(false); return prev }
return prev + 1
})
}, speed)
}
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [isPlaying, speed, steps.length])
useKeyboardControls({
enabled: steps.length > 0,
onStepForward: () => setCurrentStep(s => Math.min(s + 1, steps.length - 1)),
onStepBackward: () => setCurrentStep(s => Math.max(s - 1, 0)),
onPlayPause: () => setIsPlaying(p => !p),
onRun: handleRun,
onNextBreakpoint: () => {
const next = steps.findIndex((s, i) => i > currentStep && breakpoints.includes(s.line))
if (next !== -1) setCurrentStep(next)
},
onPrevBreakpoint: () => {
for (let i = currentStep - 1; i >= 0; i--) {
if (breakpoints.includes(steps[i].line)) { setCurrentStep(i); break }
}
},
})
const currentStepData = steps.length > 0 ? steps[currentStep] : null
return (
<div className="app">
<header className="app-header">
<h1>Universal Code Visualizer</h1>
<div className="examples">
{Object.keys(EXAMPLES).map(name => (
<button key={name} className="example-btn" onClick={() => { setCode(EXAMPLES[name]); setCurrentStep(0); setIsPlaying(false) }}>
{name}
</button>
))}
</div>
<button className="run-btn" onClick={handleRun} disabled={isRunning}>
{isRunning ? '⏳ Running...' : '▶ Run'}
</button>
</header>
<main className="app-main">
<div className="left-panel">
<div className="editor-container">
{/* CodeMirror Editor renders here */}
<textarea
className="code-textarea"
value={code}
onChange={e => setCode(e.target.value)}
spellCheck={false}
/>
</div>
<div className="state-inspector">
{currentStepData && (
<>
<div className="inspector-section">
<h4>Variables</h4>
<div className="var-list">
{Object.entries(currentStepData.variables).map(([name, value]) => (
<div key={name} className={`var-item ${currentStepData.diff?.changedVars.includes(name) ? 'changed' : ''}`}>
<span className="var-name">{name}</span>
<span className="var-value">{JSON.stringify(value) ?? 'undefined'}</span>
</div>
))}
</div>
</div>
<div className="inspector-section">
<h4>Call Stack</h4>
{currentStepData.callStack.map((frame, i) => (
<div key={i} className="stack-frame">{frame.name} :L{frame.line}</div>
))}
</div>
{currentStepData.stdout.length > 0 && (
<div className="inspector-section">
<h4>Console</h4>
{currentStepData.stdout.map((line, i) => (
<div key={i} className="stdout-line">{line}</div>
))}
</div>
)}
</>
)}
</div>
</div>
<div className="right-panel">
<div className="viz-placeholder">
{/* VisualizationPanel renders detected structures here */}
{currentStepData ? (
<div className="viz-info">
Step {currentStep + 1}/{steps.length} | Line {currentStepData.line} | {currentStepData.nodeType}
{currentStepData.diff && (
<span className="diff-info">
{currentStepData.diff.created.length > 0 && ` +${currentStepData.diff.created.length}`}
{currentStepData.diff.modified.length > 0 && ` ~${currentStepData.diff.modified.length}`}
{currentStepData.diff.deleted.length > 0 && ` -${currentStepData.diff.deleted.length}`}
</span>
)}
</div>
) : (
<div className="viz-empty">Paste code and hit Run to visualize execution</div>
)}
</div>
</div>
</main>
{error && <div className="error-bar">{error}</div>}
{steps.length > 0 && (
<footer className="app-footer">
<div className="timeline">
<button className="timeline-btn" onClick={() => setCurrentStep(s => Math.max(0, s - 1))}>◀</button>
<button className="timeline-btn" onClick={() => setIsPlaying(!isPlaying)}>{isPlaying ? '⏸' : '▶'}</button>
<button className="timeline-btn" onClick={() => setCurrentStep(s => Math.min(steps.length - 1, s + 1))}>▶▶</button>
<input type="range" min={0} max={steps.length - 1} value={currentStep} onChange={e => setCurrentStep(+e.target.value)} className="timeline-slider" />
<span className="timeline-info">Step {currentStep + 1}/{steps.length}</span>
<input type="range" min={50} max={3000} step={50} value={speed} onChange={e => setSpeed(+e.target.value)} className="speed-slider" />
<span className="speed-label">{speed}ms</span>
</div>
</footer>
)}
</div>
)
}
export default App
- Now open src/index.css (also from the Vite scaffold) and replace its entire contents with the dark theme styles:
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: #0a0a1a;
color: #e0e7ff;
overflow: hidden;
height: 100vh;
}
#root { height: 100vh; }
.app { display: grid; grid-template-rows: 52px 1fr auto; height: 100vh; }
.app-header {
display: flex; align-items: center; gap: 12px; padding: 0 16px;
background: linear-gradient(180deg, #151530 0%, #0f0f25 100%);
border-bottom: 1px solid #2a2a5a;
}
.app-header h1 { font-size: 15px; font-weight: 700; color: #818cf8; letter-spacing: -0.5px; }
.examples { display: flex; gap: 6px; margin-left: 16px; overflow-x: auto; }
.example-btn {
padding: 5px 10px; border: 1px solid #3730a3; background: transparent;
color: #a5b4fc; border-radius: 4px; font-size: 11px; cursor: pointer;
white-space: nowrap; transition: all 0.15s;
}
.example-btn:hover { background: #3730a3; color: #fff; }
.run-btn {
margin-left: auto; padding: 7px 18px; background: #4f46e5; color: white;
border: none; border-radius: 5px; font-size: 13px; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.run-btn:hover { background: #6366f1; }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.app-main { display: grid; grid-template-columns: 1fr 1.2fr; overflow: hidden; }
.left-panel { display: grid; grid-template-rows: 1fr 220px; border-right: 1px solid #1e1e4a; overflow: hidden; }
.editor-container { overflow: hidden; }
.code-textarea {
width: 100%; height: 100%; background: #0d0d20; color: #e0e7ff; border: none;
padding: 16px; font-family: inherit; font-size: 13px; line-height: 1.6; resize: none; outline: none;
}
.right-panel { overflow: auto; padding: 20px; background: #08081a; }
.viz-empty, .viz-placeholder {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: #4b5563; font-size: 13px;
}
.viz-info { font-size: 12px; color: #9ca3af; font-family: monospace; }
.diff-info { color: #34d399; margin-left: 8px; }
.state-inspector { padding: 12px; background: #0d0d25; border-top: 1px solid #1e1e4a; overflow-y: auto; }
.inspector-section { margin-bottom: 12px; }
.inspector-section h4 {
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
color: #6366f1; margin-bottom: 6px; font-weight: 700;
}
.var-list { display: flex; flex-direction: column; gap: 2px; }
.var-item {
display: flex; justify-content: space-between; padding: 3px 8px;
border-radius: 3px; font-size: 11px; transition: background 0.2s;
}
.var-item.changed { background: #1a2e1a; }
.var-name { color: #a5b4fc; }
.var-value { color: #34d399; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.stack-frame { padding: 3px 8px; background: #12122e; border-radius: 3px; margin-bottom: 2px; font-size: 11px; }
.stdout-line { color: #34d399; font-size: 11px; padding: 2px 0; }
.error-bar { background: #7f1d1d; color: #fecaca; padding: 8px 16px; font-size: 12px; }
.app-footer { border-top: 1px solid #1e1e4a; background: #0f0f25; padding: 10px 16px; }
.timeline { display: flex; align-items: center; gap: 10px; }
.timeline-btn {
width: 30px; height: 30px; border: 1px solid #3730a3; background: transparent;
color: #a5b4fc; border-radius: 4px; cursor: pointer; font-size: 11px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.timeline-btn:hover { background: #3730a3; color: white; }
.timeline-slider { flex: 1; accent-color: #6366f1; height: 4px; }
.timeline-info { font-size: 11px; color: #6b7280; min-width: 90px; }
.speed-slider { width: 80px; accent-color: #6366f1; }
.speed-label { font-size: 10px; color: #6b7280; min-width: 45px; }
What does this stylesheet do?
The CSS establishes the full dark theme layout using CSS Grid. The .app grid splits the viewport into three rows: a 52px header, a flexible main area, and an auto-height footer for the timeline.
The .app-main splits horizontally into the editor panel (left) and visualization panel (right). Indigo accent colors (#818cf8, #6366f1) provide visual consistency. The .var-item.changed class highlights variables that changed on the current step with a subtle green background.
- Save src/index.css and refresh your browser at http://localhost:5173.
- You should see a polished dark-themed application with indigo header text reading "Universal Code Visualizer", five example buttons across the header, a code textarea on the left showing the BST example, and a purple Run button on the right side of the header.
App looks unstyled or broken?
Make sure src/main.tsx still imports './index.css'. The Vite scaffold includes this import by default. If you accidentally removed it, add it back.
Also check that you replaced the entire contents of index.css rather than appending below the old Vite styles.
help me fix my app layout and styling
✔️ Awesome, I've got everything!
Great. Make sure both src/App.tsx and src/index.css are saved.
ⓧ I'd like to double check the full code
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
background: #0a0a1a;
color: #e0e7ff;
overflow: hidden;
height: 100vh;
}
#root { height: 100vh; }
.app { display: grid; grid-template-rows: 52px 1fr auto; height: 100vh; }
.app-header {
display: flex; align-items: center; gap: 12px; padding: 0 16px;
background: linear-gradient(180deg, #151530 0%, #0f0f25 100%);
border-bottom: 1px solid #2a2a5a;
}
.app-header h1 { font-size: 15px; font-weight: 700; color: #818cf8; letter-spacing: -0.5px; }
.examples { display: flex; gap: 6px; margin-left: 16px; overflow-x: auto; }
.example-btn {
padding: 5px 10px; border: 1px solid #3730a3; background: transparent;
color: #a5b4fc; border-radius: 4px; font-size: 11px; cursor: pointer;
white-space: nowrap; transition: all 0.15s;
}
.example-btn:hover { background: #3730a3; color: #fff; }
.run-btn {
margin-left: auto; padding: 7px 18px; background: #4f46e5; color: white;
border: none; border-radius: 5px; font-size: 13px; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.run-btn:hover { background: #6366f1; }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.app-main { display: grid; grid-template-columns: 1fr 1.2fr; overflow: hidden; }
.left-panel { display: grid; grid-template-rows: 1fr 220px; border-right: 1px solid #1e1e4a; overflow: hidden; }
.editor-container { overflow: hidden; }
.code-textarea {
width: 100%; height: 100%; background: #0d0d20; color: #e0e7ff; border: none;
padding: 16px; font-family: inherit; font-size: 13px; line-height: 1.6; resize: none; outline: none;
}
.right-panel { overflow: auto; padding: 20px; background: #08081a; }
.viz-empty, .viz-placeholder {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: #4b5563; font-size: 13px;
}
.viz-info { font-size: 12px; color: #9ca3af; font-family: monospace; }
.diff-info { color: #34d399; margin-left: 8px; }
.state-inspector { padding: 12px; background: #0d0d25; border-top: 1px solid #1e1e4a; overflow-y: auto; }
.inspector-section { margin-bottom: 12px; }
.inspector-section h4 {
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
color: #6366f1; margin-bottom: 6px; font-weight: 700;
}
.var-list { display: flex; flex-direction: column; gap: 2px; }
.var-item {
display: flex; justify-content: space-between; padding: 3px 8px;
border-radius: 3px; font-size: 11px; transition: background 0.2s;
}
.var-item.changed { background: #1a2e1a; }
.var-name { color: #a5b4fc; }
.var-value { color: #34d399; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.stack-frame { padding: 3px 8px; background: #12122e; border-radius: 3px; margin-bottom: 2px; font-size: 11px; }
.stdout-line { color: #34d399; font-size: 11px; padding: 2px 0; }
.error-bar { background: #7f1d1d; color: #fecaca; padding: 8px 16px; font-size: 12px; }
.app-footer { border-top: 1px solid #1e1e4a; background: #0f0f25; padding: 10px 16px; }
.timeline { display: flex; align-items: center; gap: 10px; }
.timeline-btn {
width: 30px; height: 30px; border: 1px solid #3730a3; background: transparent;
color: #a5b4fc; border-radius: 4px; cursor: pointer; font-size: 11px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.timeline-btn:hover { background: #3730a3; color: white; }
.timeline-slider { flex: 1; accent-color: #6366f1; height: 4px; }
.timeline-info { font-size: 11px; color: #6b7280; min-width: 90px; }
.speed-slider { width: 80px; accent-color: #6366f1; }
.speed-label { font-size: 10px; color: #6b7280; min-width: 45px; }
- Click the Run button in the header.
- After a moment, you should see a timeline appear at the bottom with step controls and a range slider. The left panel's Variables section populates with variable names and values from the BST example.
Why does the timeline only appear after clicking Run?
The footer is conditionally rendered with steps.length > 0. Until the interpreter produces execution steps, there is nothing to scrub through, so the timeline stays hidden. This keeps the UI clean when no code has been executed yet.
Secret mission
Add a Force-Directed Graph Mode Renderer
Your visualizer handles trees and linked lists beautifully. But what about arbitrary object graphs with cycles, weighted edges, and adjacency lists? In this secret mission, you'll build a dedicated Graph Mode renderer powered by a spring-force simulation, complete with cycle detection, dashed back-edges, and edge weights.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine. There are no cloud resources, no paid APIs, and no ongoing costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The code-visualizer project folder on your Desktop (source code, configuration files).
- Node.js packages installed in node_modules (Acorn, CodeMirror, React, TypeScript, Vite).
- The Vite dev server process running at http://localhost:5173.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with new examples and features.
- Your dev server stays running at http://localhost:5173.
- Your project files remain on your Desktop.
- You can resume editing and testing any time.
Pause - I'll come back to this later
Shut down the dev server to free up the terminal and port. Your project files stay intact for when you return.
- In the terminal where the dev server is running, press Ctrl+C to stop the Vite dev server.
- When you come back, navigate to the project folder and run npm run dev to start it again.
Delete - I don't want to use this again
Remove all project files and free up disk space. This cannot be undone.
- In the terminal where the dev server is running, press Ctrl+C to stop the Vite dev server.
- Delete the project folder by running the command for your operating system.
macOS
Run this command to remove the project folder:
rm -rf ~/Desktop/code-visualizer
Windows
Run this command to remove the project folder:
rmdir /s /q %USERPROFILE%\Desktop\code-visualizer
- Confirm the folder is gone by running ls ~/Desktop (macOS) or checking your Desktop in File Explorer (Windows). You should no longer see code-visualizer listed.
That's a Wrap!
That's a Wrap!
Nice work. You just built a professional-grade universal code visualizer from scratch. This is a tool that parses arbitrary JavaScript, executes it step by step in a sandboxed interpreter, auto-detects the shape of every data structure in memory, and renders animated visual diagrams with full time-travel debugging.
You've learned how to:
- Build a complete custom AST interpreter that parses JavaScript with Acorn, supports classes, closures, recursion, and destructuring, and captures full program state (heap, scope chain, call stack) at every statement.
- Engineer a weighted heuristic shape classifier that auto-detects binary trees, linked lists, doubly-linked lists, graphs, and hash maps from runtime object property patterns using confidence scoring.
- Implement the Reingold-Tilford tree layout algorithm, a professional timeline with keyboard shortcuts, heap diffing between execution steps, and animated SVG transitions using the FLIP technique.
- Build a force-directed graph renderer with spring-force simulation physics, cycle detection with dashed back-edges, labeled edges showing property names, and weighted edge visualization.
Ready to quiz yourself?