From 41c2a833a4b584a18a6b52e42da4d7d9c8a224d4 Mon Sep 17 00:00:00 2001 From: Victor Del Carpio Date: Thu, 16 Jul 2026 12:28:40 -0400 Subject: [PATCH 01/15] feat: debugging now allows step into --- src/components/OutputPanel.tsx | 46 +- src/components/VariableFrames.tsx | 90 +++ src/hooks/useCodeDebugger.ts | 144 ++--- src/language/csp/emitter.ts | 17 +- src/language/debugger.ts | 334 ++---------- src/language/interpreter.ts | 842 +++++++++++++++++------------ src/language/java/emitter.ts | 19 +- src/language/javascript/emitter.ts | 14 +- src/language/praxis/emitter.ts | 12 +- src/language/python/emitter.ts | 21 +- src/pages/EditorPage.tsx | 9 +- src/pages/EmbedPage.tsx | 27 +- src/utils/debugHandlers.ts | 67 +-- src/utils/debuggerUtils.ts | 48 +- tests/debugger.test.ts | 399 ++++++++++++++ 15 files changed, 1204 insertions(+), 885 deletions(-) create mode 100644 src/components/VariableFrames.tsx create mode 100644 tests/debugger.test.ts diff --git a/src/components/OutputPanel.tsx b/src/components/OutputPanel.tsx index 9e881a8..efd0973 100644 --- a/src/components/OutputPanel.tsx +++ b/src/components/OutputPanel.tsx @@ -1,6 +1,8 @@ import React, { useState, useRef, useEffect, useId } from 'react'; import { Terminal, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; import { ResizeHandle } from './ResizeHandle'; +import { VariableFrames, formatVariableValue } from './VariableFrames'; +import type { StackFrame } from '../language/debugger'; export type OutputPanelState = 'open' | 'closed'; @@ -10,6 +12,8 @@ interface OutputPanelProps { /** False until the user's first Run/Debug — suppresses the placeholder after that. */ hasRun?: boolean; variables?: Record; + /** Debugger call stack (global scope first). When given, variables are shown per frame. */ + callStack?: StackFrame[]; showVariables?: boolean; height?: number; resizeActive?: boolean; @@ -25,29 +29,12 @@ interface OutputPanelProps { const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-900'; -const formatVariableValue = (value: any): string => { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (typeof value === 'boolean') return value.toString(); - if (typeof value === 'number') return value.toString(); - if (typeof value === 'string') return `"${value}"`; - if (Array.isArray(value)) { - return `[${value.map((v) => formatVariableValue(v)).join(', ')}]`; - } - if (typeof value === 'object' && value.klass?.name) { - return `${value.klass.name} instance`; - } - if (typeof value === 'object' && value.klass) { - return `JavaClass(${(value as any).name || 'unknown'})`; - } - return String(value); -}; - export const OutputPanel: React.FC = ({ output, error, hasRun = false, variables = {}, + callStack = [], showVariables = false, height, resizeActive = false, @@ -214,25 +201,36 @@ export const OutputPanel: React.FC = ({ className="flex flex-col border-t sm:border-t-0 sm:border-l border-slate-800 w-full sm:w-64 shrink-0 max-h-40 sm:max-h-none" aria-label="Debug variables" > -
- +
+ Variables + {callStack.length > 1 && ( + + in {callStack[callStack.length - 1].name}() + + )}
- {Object.keys(variables).length === 0 ? ( + {callStack.length > 0 ? ( + + ) : Object.keys(variables).length === 0 ? (
No variables
) : ( Object.entries(variables).map(([name, value]) => { if (typeof value === 'function' || name.startsWith('_')) return null; - const valueStr = formatVariableValue(value); return (
{name}: - {valueStr} + + {formatVariableValue(value)} +
); }) diff --git a/src/components/VariableFrames.tsx b/src/components/VariableFrames.tsx new file mode 100644 index 0000000..6b836d1 --- /dev/null +++ b/src/components/VariableFrames.tsx @@ -0,0 +1,90 @@ +/** + * Call-stack-aware variable table used while debugging (Editor and Embed pages). + * Renders one section per stack frame — innermost call on top, globals last — + * so students can watch locals appear when a function is entered and disappear + * when it returns. + */ + +import type { StackFrame } from '../language/debugger'; + +export const formatVariableValue = (value: any): string => { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'boolean') return value.toString(); + if (typeof value === 'number') return value.toString(); + if (typeof value === 'string') return `"${value}"`; + if (Array.isArray(value)) { + return `[${value.map((v) => formatVariableValue(v)).join(', ')}]`; + } + if (typeof value === 'object' && value.klass?.name) { + return `${value.klass.name} instance`; + } + return String(value); +}; + +interface VariableFramesProps { + frames: StackFrame[]; +} + +export function VariableFrames({ frames }: VariableFramesProps) { + if (frames.length === 0) { + return
No variables
; + } + + // Innermost call first (the top of the stack), global scope last. + const ordered = [...frames].reverse(); + const inFunction = frames.length > 1; + + return ( +
+ {ordered.map((frame, idx) => { + const isCurrent = inFunction && idx === 0; + const label = frame.name === 'global' ? 'Globals' : `${frame.name}()`; + const entries = Object.entries(frame.variables); + + return ( +
+
+ + {label} + + {isCurrent && ( + + current + + )} +
+
+ {entries.length === 0 ? ( +
no variables
+ ) : ( + entries.map(([name, value]) => ( +
+ {name} + + {formatVariableValue(value)} + +
+ )) + )} +
+
+ ); + })} +
+ ); +} diff --git a/src/hooks/useCodeDebugger.ts b/src/hooks/useCodeDebugger.ts index bcafaf0..de2b116 100644 --- a/src/hooks/useCodeDebugger.ts +++ b/src/hooks/useCodeDebugger.ts @@ -1,18 +1,19 @@ /** * useCodeDebugger hook that manages debugging state and logic for stepping through code execution. - * Tracks highlighted lines, variables, and debug step information for both source and translated code. + * Tracks highlighted lines, variables, and the call stack for both source and translated code. */ import { useState, useCallback } from 'react'; import type { Program } from '../language/ast'; import type { SupportedLang } from '../components/LanguageSelector'; -import { Debugger, type DebugStep } from '../language/debugger'; -import { getRangeLines, findNodesAtLocation } from '../utils/debuggerUtils'; +import { Debugger, type DebugStep, type StackFrame } from '../language/debugger'; +import { getRangeLines } from '../utils/debuggerUtils'; import type { SourceMap } from './useCodeParsing'; interface DebugStepResult { sourceHighlightedLines: number[]; translationHighlightedLines: number[]; + /** Cumulative program output up to this step. */ outputLines: string[]; isComplete: boolean; step: DebugStep | null; @@ -33,25 +34,23 @@ export const useCodeDebugger = ( const [highlightedSourceLines, setHighlightedSourceLines] = useState([]); const [highlightedTranslationLines, setHighlightedTranslationLines] = useState([]); const [currentVariables, setCurrentVariables] = useState>({}); + const [currentCallStack, setCurrentCallStack] = useState([]); const [waitingForInput, setWaitingForInput] = useState(false); const [inputPrompt, setInputPrompt] = useState(''); const initDebugger = useCallback( (ast: Program | null, lang: SupportedLang, sourceCode: string = '') => { if (!ast) return; - try { - const debugInstance = new Debugger(); - debugInstance.init(ast, lang, sourceCode); - setDebuggerInstance(debugInstance); - setIsDebugging(true); - setIsDebugComplete(false); - setCurrentVariables({}); - setHighlightedSourceLines([]); - setHighlightedTranslationLines([]); - return true; - } catch (e) { - throw e; - } + const debugInstance = new Debugger(); + debugInstance.init(ast, lang, sourceCode); + setDebuggerInstance(debugInstance); + setIsDebugging(true); + setIsDebugComplete(false); + setCurrentVariables({}); + setCurrentCallStack([]); + setHighlightedSourceLines([]); + setHighlightedTranslationLines([]); + return true; }, [] ); @@ -64,90 +63,39 @@ export const useCodeDebugger = ( ): DebugStepResult | null => { if (!debuggerInstance || !ast || !sourceCode) return null; - try { - const step = debuggerInstance.step(); - if (!step) return null; - - setCurrentVariables(step.variables); - - // Handle input prompt - if (step.waitingForInput) { - setWaitingForInput(true); - setInputPrompt(step.inputPrompt || ''); - return { - sourceHighlightedLines: [], - translationHighlightedLines: [], - outputLines: [step.inputPrompt || 'Waiting for input...'], - isComplete: false, - step, - }; - } - - setWaitingForInput(false); - setInputPrompt(''); - - let sourceHighlightedLines: number[] = []; - let translationHighlightedLines: number[] = []; - - if (step.sourceLocation) { - const nodesAtLocation = findNodesAtLocation(ast, step.sourceLocation.start); - - // Highlight source lines - const sourceHighlights = new Set(); - for (const node of nodesAtLocation) { - if (node.loc) { - const nodeLines = getRangeLines(sourceCode, node.loc.start); - nodeLines.forEach((line) => sourceHighlights.add(line)); - } - } - sourceHighlightedLines = Array.from(sourceHighlights); - - // Highlight translation lines - const translation = getTranslation(ast, currentTargetLang); - const translationSourceMap = translation.sourceMap; - const nodeIds = nodesAtLocation.map((n) => n.id); - - for (const nodeId of nodeIds) { - const mapEntry = translationSourceMap.get - ? translationSourceMap.get(nodeId) - : (translationSourceMap as any)[nodeId]; - - if (mapEntry !== undefined) { - if (typeof mapEntry === 'object' && 'lineStart' in mapEntry) { - translationHighlightedLines.push(mapEntry.lineStart - 1); - } else if (typeof mapEntry === 'number') { - translationHighlightedLines.push(mapEntry); - } - } - } - } - - setHighlightedSourceLines(sourceHighlightedLines); - setHighlightedTranslationLines(translationHighlightedLines); - - const outputLines: string[] = []; - outputLines.push(`--- Step ${step.stepNumber} ---`); - outputLines.push(`Node: ${step.nodeType}`); - if (step.sourceLocation) { - outputLines.push(`Location: ${step.sourceLocation.start} - ${step.sourceLocation.end}`); - } - outputLines.push(''); + const step = debuggerInstance.step(); + if (!step) return null; + + setCurrentVariables(step.variables); + setCurrentCallStack(step.callStack); + setWaitingForInput(Boolean(step.waitingForInput)); + setInputPrompt(step.waitingForInput ? step.inputPrompt || '' : ''); + + // Highlight exactly the node being executed — in the source via its + // character range, in the translation via the emitter's source map. + const sourceHighlightedLines = step.sourceLocation + ? getRangeLines(sourceCode, step.sourceLocation.start) + : []; + let translationHighlightedLines: number[] = []; + if (step.nodeId) { + const line = getTranslation(ast, currentTargetLang).sourceMap.get(step.nodeId); + if (line !== undefined) translationHighlightedLines = [line]; + } - if (step.isComplete) { - setIsDebugComplete(true); - outputLines.push('Execution complete.'); - } + setHighlightedSourceLines(sourceHighlightedLines); + setHighlightedTranslationLines(translationHighlightedLines); - return { - sourceHighlightedLines, - translationHighlightedLines, - outputLines, - isComplete: step.isComplete, - step, - }; - } catch (e) { - throw e; + if (step.isComplete) { + setIsDebugComplete(true); } + + return { + sourceHighlightedLines, + translationHighlightedLines, + outputLines: step.output, + isComplete: step.isComplete, + step, + }; }, [debuggerInstance, getTranslation] ); @@ -159,6 +107,7 @@ export const useCodeDebugger = ( setHighlightedTranslationLines([]); setIsDebugComplete(false); setCurrentVariables({}); + setCurrentCallStack([]); setWaitingForInput(false); setInputPrompt(''); }, []); @@ -185,6 +134,7 @@ export const useCodeDebugger = ( setHighlightedTranslationLines, currentVariables, setCurrentVariables, + currentCallStack, waitingForInput, inputPrompt, initDebugger, diff --git a/src/language/csp/emitter.ts b/src/language/csp/emitter.ts index cdaa43c..a75a1f4 100644 --- a/src/language/csp/emitter.ts +++ b/src/language/csp/emitter.ts @@ -207,10 +207,13 @@ export class CSPEmitter extends ASTVisitor { stmt.cases.forEach((c: any) => { if (c.test) { const testStr = this.generateExpression(c.test, 0); - this.emit(first ? `IF (${disc} = ${testStr})` : `ELSE IF (${disc} = ${testStr})`); + this.emit( + first ? `IF (${disc} = ${testStr})` : `ELSE IF (${disc} = ${testStr})`, + first ? stmt.id : undefined + ); first = false; } else { - this.emit('ELSE'); + this.emit('ELSE', first ? stmt.id : undefined); } this.emit('{'); this.indent(); @@ -220,12 +223,12 @@ export class CSPEmitter extends ASTVisitor { }); } - visitBreak(_stmt: any): void { - this.emit('// BREAK'); + visitBreak(stmt: any): void { + this.emit('// BREAK', stmt.id); } - visitContinue(_stmt: any): void { - this.emit('// CONTINUE'); + visitContinue(stmt: any): void { + this.emit('// CONTINUE', stmt.id); } visitFor(stmt: any): void { @@ -343,7 +346,7 @@ export class CSPEmitter extends ASTVisitor { visitTry(stmt: any): void { // CSP has no exception handling — emit as comment block - this.emit('// TRY'); + this.emit('// TRY', stmt.id); this.emit('{'); this.indent(); this.visitBlock(stmt.body); diff --git a/src/language/debugger.ts b/src/language/debugger.ts index 7fb5e84..0a2283d 100644 --- a/src/language/debugger.ts +++ b/src/language/debugger.ts @@ -1,14 +1,17 @@ /** - * Debugger implementation that provides step-by-step code execution with variable inspection. - * Tracks execution state and maps debug information back to source code locations. + * Debugger that provides step-by-step code execution with variable inspection. + * Wraps the interpreter's step-through generator, tracking the collected steps, + * the call stack, and input-pause state for the UI. */ -import type { Program, ASTNode } from './ast'; +import type { Program } from './ast'; import { Interpreter, InputPrompt } from './interpreter'; -import { Translator } from './translator'; +import type { DebugStepEvent, StackFrame } from './interpreter'; export type SupportedLang = 'python' | 'java' | 'csp' | 'praxis' | 'javascript' | 'blocks' | 'ast'; +export type { StackFrame } from './interpreter'; + /** * Represents a single execution step with all relevant debug information */ @@ -18,6 +21,8 @@ export interface DebugStep { nodeType: string; sourceLocation: { start: number; end: number } | null; variables: Record; + /** Call stack at this step: global scope first, innermost call last. */ + callStack: StackFrame[]; output: string[]; isComplete: boolean; error?: string; @@ -25,19 +30,6 @@ export interface DebugStep { inputPrompt?: string; } -/** - * Maps AST node IDs to line numbers in translated code - */ -export interface SourceMap { - [nodeId: string]: { - language: SupportedLang; - lineStart: number; - lineEnd: number; - columnStart?: number; - columnEnd?: number; - }; -} - /** * Debug context that maintains state across stepping operations */ @@ -45,58 +37,38 @@ export interface DebugContext { program: Program; sourceLang: SupportedLang; interpreter: Interpreter; - translator: Translator; steps: DebugStep[]; currentStep: number; - isRunning: boolean; - sourceMaps: Map; sourceCode: string; - waitingForInput?: boolean; + waitingForInput: boolean; inputPrompt?: string; } /** * Main debugger class that orchestrates stepping through code - * with execution state tracking and source mapping + * with execution state tracking */ export class Debugger { private context: DebugContext | null = null; - private executionGenerator: Generator | null = null; - private stepCallCount: number = 0; // Counter for debugging - private pendingRetryAfterInput: boolean = false; // Track if we're retrying after input pause + private executionGenerator: Generator | null = null; /** * Initialize debugger with a program and source language */ init(program: Program, sourceLang: SupportedLang, sourceCode: string = ''): DebugContext { const interpreter = new Interpreter(); - const translator = new Translator(); this.context = { program, sourceLang, interpreter, - translator, steps: [], currentStep: 0, - isRunning: false, - sourceMaps: new Map(), sourceCode, waitingForInput: false, }; - // Generate source maps for all target languages - const targetLanguages: SupportedLang[] = ['python', 'java', 'csp', 'praxis', 'javascript']; - for (const lang of targetLanguages) { - if (lang !== 'ast') { - const sourceMap = this.generateSourceMap(program, lang as any); - this.context.sourceMaps.set(lang, sourceMap); - } - } - - // Initialize execution generator - this.executionGenerator = (interpreter as any).stepThroughWithState(program, sourceCode); - this.pendingRetryAfterInput = false; + this.executionGenerator = interpreter.stepThroughWithState(program, sourceCode); return this.context; } @@ -107,124 +79,81 @@ export class Debugger { step(): DebugStep | null { if (!this.context || !this.executionGenerator) return null; - this.stepCallCount++; - // console.log(`\n=== Debugger.step() #${this.stepCallCount} START ===`); - try { - if (this.pendingRetryAfterInput) { - // console.log(`Debugger.step() #${this.stepCallCount}: Retrying after input was provided`); - } - const result = this.executionGenerator.next(); - // console.log( - // `Debugger.step() #${this.stepCallCount}: generator.next() returned, done=${result.done}` - // ); - - // If we were retrying and it succeeded, clear the flag - if (this.pendingRetryAfterInput) { - this.pendingRetryAfterInput = false; - } if (result.done) { this.context.waitingForInput = false; - // console.log(`=== Debugger.step() #${this.stepCallCount} END (complete) ===\n`); - return { - stepNumber: this.context.steps.length, + return this.recordStep({ nodeId: '', nodeType: 'Program', sourceLocation: null, - variables: this.extractVariables(), - output: (this.context.interpreter as any).getOutput?.() || [], isComplete: true, - }; - } - - const { nodeId, nodeType, loc, variables, prompt } = result.value; - - // Handle InputPrompt yielded from interpreter - if (nodeType === 'InputPrompt') { - // console.log( - // `Debugger.step() #${this.stepCallCount}: Yielded InputPrompt, waiting for input` - // ); - this.pendingRetryAfterInput = true; - this.context.waitingForInput = true; - this.context.inputPrompt = prompt || ''; - - const step: DebugStep = { - stepNumber: this.context.steps.length, - nodeId, - nodeType: 'InputPrompt', - sourceLocation: loc || null, - variables, - output: (this.context.interpreter as any).getOutput?.() || [], - isComplete: false, - waitingForInput: true, - inputPrompt: prompt || '', - }; - this.context.steps.push(step); - // console.log(`=== Debugger.step() #${this.stepCallCount} END (InputPrompt) ===\n`); - return step; + }); } - const step: DebugStep = { - stepNumber: this.context.steps.length, - nodeId, - nodeType, - sourceLocation: loc || null, - variables, - output: (this.context.interpreter as any).getOutput?.() || [], + const event = result.value; + const waitingForInput = event.nodeType === 'InputPrompt'; + this.context.waitingForInput = waitingForInput; + this.context.inputPrompt = waitingForInput ? event.prompt || '' : undefined; + + return this.recordStep({ + nodeId: event.nodeId, + nodeType: event.nodeType, + sourceLocation: event.loc, + variables: event.variables, + callStack: event.callStack, isComplete: false, - }; - - this.context.steps.push(step); - this.context.currentStep = this.context.steps.length - 1; - - // console.log(`=== Debugger.step() #${this.stepCallCount} END (step ${nodeType}) ===\n`); - return step; + ...(waitingForInput && { waitingForInput: true, inputPrompt: event.prompt || '' }), + }); } catch (error: any) { - // console.log( - // `Debugger.step() #${this.stepCallCount}: Caught error:`, - // error.name, - // error.prompt || '' - // ); - // Handle InputPrompt specially + // An InputPrompt escaping the generator means it can no longer resume — + // record the pause so the UI can collect input, though stepping past it + // requires the paths inside the generator (which all handle it there). if (error instanceof InputPrompt) { - this.pendingRetryAfterInput = true; this.context.waitingForInput = true; this.context.inputPrompt = error.prompt; - const step: DebugStep = { - stepNumber: this.context.steps.length, + return this.recordStep({ nodeId: '', nodeType: 'InputPrompt', sourceLocation: null, - variables: this.extractVariables(), - output: (this.context.interpreter as any).getOutput?.() || [], isComplete: false, waitingForInput: true, inputPrompt: error.prompt, - }; - this.context.steps.push(step); - // console.log(`=== Debugger.step() #${this.stepCallCount} END (InputPrompt) ===\n`); - return step; + }); } - const errorStep: DebugStep = { - stepNumber: this.context.steps.length, + return this.recordStep({ nodeId: '', nodeType: 'Error', sourceLocation: null, - variables: this.extractVariables(), - output: (this.context.interpreter as any).getOutput?.() || [], isComplete: true, error: error.message, - }; - - this.context.steps.push(errorStep); - // console.log(`=== Debugger.step() #${this.stepCallCount} END (error) ===\n`); - return errorStep; + }); } } + /** Fills in the shared step fields (variables, output, numbering) and stores the step. */ + private recordStep( + partial: Omit & + Partial> + ): DebugStep { + const context = this.context!; + const callStack = partial.callStack ?? context.interpreter.getStackFrames(); + const globals = callStack[0]?.variables ?? {}; + const locals = callStack[callStack.length - 1]?.variables ?? {}; + const step: DebugStep = { + variables: partial.variables ?? { ...globals, ...locals }, + callStack, + output: [...context.interpreter.getOutput()], + stepNumber: context.steps.length, + ...partial, + }; + context.steps.push(step); + context.currentStep = context.steps.length - 1; + return step; + } + /** * Get current debug step */ @@ -234,133 +163,10 @@ export class Debugger { } /** - * Get the line number range in the source language for a node - */ - getSourceLineRange(): { start: number; end: number } | null { - const currentStep = this.getCurrentStep(); - if (!currentStep || !currentStep.sourceLocation) return null; - - return { - start: currentStep.sourceLocation.start, - end: currentStep.sourceLocation.end, - }; - } - - /** - * Get line number ranges in translated language - */ - getTranslatedLineRange( - nodeId: string, - targetLang: SupportedLang - ): { start: number; end: number } | null { - if (!this.context) return null; - - const sourceMap = this.context.sourceMaps.get(targetLang); - if (!sourceMap || !sourceMap[nodeId]) return null; - - const mapping = sourceMap[nodeId]; - return { - start: mapping.lineStart, - end: mapping.lineEnd, - }; - } - - /** - * Get all translated line ranges for current step - */ - getAllTranslatedRanges(targetLang: SupportedLang): { start: number; end: number } | null { - const currentStep = this.getCurrentStep(); - if (!currentStep) return null; - - return this.getTranslatedLineRange(currentStep.nodeId, targetLang); - } - - /** - * Generate a source map for a target language - * Maps AST node IDs to their positions in translated code - */ - private generateSourceMap(program: Program, targetLang: SupportedLang): SourceMap { - const sourceMap: SourceMap = {}; - - try { - this.context!.translator.translate( - program, - targetLang === 'ast' ? 'python' : (targetLang as any) - ); - - // Parse the translated code to build line mappings - let currentLine = 1; - - const walkAST = (node: ASTNode) => { - if (node?.loc) { - // Map this node to the corresponding line in translated code - // For now, use a simple heuristic: estimate line position based on node depth - // This can be enhanced with more sophisticated source mapping - sourceMap[node.id] = { - language: targetLang, - lineStart: currentLine, - lineEnd: currentLine, - }; - } - - // Recursively walk children - for (const key in node) { - const child = (node as any)[key]; - if (child && typeof child === 'object') { - if (Array.isArray(child)) { - child.forEach((c) => { - if (c && typeof c === 'object' && 'type' in c) { - walkAST(c); - currentLine++; - } - }); - } else if ('type' in child) { - walkAST(child); - } - } - } - }; - - walkAST(program); - } catch (e) { - // If source mapping fails, return empty map - } - - return sourceMap; - } - - /** - * Extract current variable state from the interpreter's environment + * Get all collected steps */ - private extractVariables(): Record { - if (!this.context) return {}; - - // Access the interpreter's environment - const interpreter = this.context.interpreter as any; - const env = interpreter.globalEnv || interpreter.currentEnv; - - if (!env) return {}; - - try { - // Extract all variables from the current environment - const variables: Record = {}; - - // Try to get values property from the environment - if (env.values) { - return { ...env.values }; - } - - // Fallback: try to extract via reflection - for (const key in env) { - if (key !== 'parent' && typeof env[key] !== 'function') { - variables[key] = env[key]; - } - } - - return variables; - } catch (e) { - return {}; - } + getSteps(): DebugStep[] { + return this.context?.steps || []; } /** @@ -374,33 +180,13 @@ export class Debugger { } } - /** - * Get debugging context - */ - getContext(): DebugContext | null { - return this.context; - } - - /** - * Get all collected steps - */ - getSteps(): DebugStep[] { - return this.context?.steps || []; - } - /** * Provide input to the debugger and resume execution */ provideInput(input: string): void { - // console.log('Debugger.provideInput called with:', input); - if (!this.context) { - // console.log('No context!'); - return; - } - // console.log('Calling interpreter.addInput'); + if (!this.context) return; this.context.interpreter.addInput(input); this.context.waitingForInput = false; - // console.log('provideInput complete'); } /** diff --git a/src/language/interpreter.ts b/src/language/interpreter.ts index 43a2872..53694b8 100644 --- a/src/language/interpreter.ts +++ b/src/language/interpreter.ts @@ -100,6 +100,77 @@ export class InputPrompt extends Error { } } +/** One entry of the debugger's call stack: a function/method invocation and the + * variables local to it. The bottom entry is always the global scope. */ +export interface StackFrame { + name: string; + variables: Record; +} + +/** What the step-through generator yields to the debugger after each step. */ +export interface DebugStepEvent { + nodeId: string; + nodeType: string; + loc: { start: number; end: number } | null; + /** Flat "visible right now" view: globals shadowed by the current frame's locals. */ + variables: Record; + /** Global scope first, innermost call last. */ + callStack: StackFrame[]; + prompt?: string; +} + +/** A user-written callable the debugger can step into. */ +interface ResolvedCallable { + name: string; + decl: FunctionDeclaration | MethodDeclaration; + thisInstance?: JavaInstance; +} + +// Call names the identifier-call ladder in `evaluate` dispatches as built-ins +// BEFORE looking up user functions. The debugger's resolveUserCallable must skip +// them so debug-mode dispatch matches run mode. Keep in sync with that ladder. +const BUILTIN_CALL_NAMES = new Set([ + 'Scanner', + 'Random', + 'super', + 'input', + 'INPUT', + 'len', + 'LENGTH', + 'range', + 'APPEND', + 'INSERT', + 'REMOVE', + 'int', + 'INT', + 'float', + 'FLOAT', + 'str', + 'String', + 'STRING', + 'bool', + 'BOOL', + 'boolean', + 'parseInt', + 'parseFloat', + 'Number', + 'Boolean', + 'random', + 'RANDOM', + 'randomInt', + 'RANDOMINT', + 'randomSeed', + 'RANDOMSEED', + 'setSeed', + 'ArrayList', + 'List', + 'Array', +]); + +// Numeric built-ins that `evaluate` resolves after user functions but before +// sibling methods — a sibling method with one of these names is never reached. +const NUMERIC_BUILTIN_NAMES = new Set(['min', 'max', 'abs', 'sqrt', 'log']); + // OOP Classes class JavaClass { name: string; @@ -215,6 +286,14 @@ export class Interpreter { private inputHandler?: (prompt: string) => string; // Callback for collecting input in normal mode private seededRandom: (() => number) | null = null; + // --- Debug-mode state (only used on the stepThroughWithState path) --- + /** Active user-function invocations, innermost last. */ + private debugCallStack: Array<{ name: string; env: Environment }> = []; + /** Stack of per-expression caches of stepped-through call results, keyed by + * CallExpression node id (see resolveUserCalls). `evaluate` consults the top + * cache so a call whose body was already stepped through isn't run twice. */ + private debugCallResults: Array> = []; + setInputQueue(inputs: string[]) { this.inputQueue = [...inputs]; } @@ -398,6 +477,8 @@ export class Interpreter { this.classes = new Map(); this.isDebugging = false; // Not in debug mode for normal execution this.seededRandom = null; + this.debugCallStack = []; + this.debugCallResults = []; try { // First pass: register all classes and procedures @@ -448,11 +529,7 @@ export class Interpreter { *stepThroughWithState( program: Program, sourceCode: string = '' - ): Generator< - { nodeId: string; nodeType: string; loc: any; variables: Record; prompt?: string }, - string[], - void - > { + ): Generator { this.sourceCode = sourceCode; this.output = []; this.outputLineBuffer = ''; @@ -460,6 +537,8 @@ export class Interpreter { this.currentEnv = this.globalEnv; this.isDebugging = true; // We're in debug mode for step-through execution this.seededRandom = null; + this.debugCallStack = []; + this.debugCallResults = []; try { // First pass: register all classes and procedures @@ -477,13 +556,29 @@ export class Interpreter { ); yield* this.executeBlockGeneratorWithState(statements, this.globalEnv); - // Third pass: if there's a Main class, execute its main() method + // Third pass: if there's a Main class, execute its main() method. Mirror + // the normal-run path (callMethod): bind `this` so bare calls to sibling + // static methods resolve, and give main its own stack frame. if (this.classes.has('Main')) { const mainClass = this.classes.get('Main')!; const mainMethod = mainClass.getMethod('main'); if (mainMethod) { - // Execute the main method's body directly instead of synthesizing a call - yield* this.executeBlockGeneratorWithState(mainMethod.body.body, this.globalEnv); + const mainInstance = new JavaInstance(mainClass); + const mainEnv = new Environment(this.globalEnv); + mainEnv.define('this', mainInstance); + mainEnv.define('self', mainInstance); + // Java's `main(String[] args)` receives an empty argument array. + if (mainMethod.params.length > 0) { + this.bindParams(mainMethod.params, [[]], mainEnv); + } + this.debugCallStack.push({ name: 'main', env: mainEnv }); + try { + yield* this.executeBlockGeneratorWithState(mainMethod.body.body, mainEnv); + } catch (e) { + if (!(e instanceof ReturnException)) throw e; // `return` from main just ends it + } finally { + this.debugCallStack.pop(); + } } } } catch (e: any) { @@ -505,405 +600,481 @@ export class Interpreter { return this.output; } + /** Snapshot of the call stack for the debugger: global scope first, then one + * frame per active user-function call, innermost last. */ + getStackFrames(): StackFrame[] { + const frames: StackFrame[] = [ + { name: 'global', variables: this.snapshotFrameVariables(this.globalEnv) }, + ]; + for (const frame of this.debugCallStack) { + frames.push({ name: frame.name, variables: this.snapshotFrameVariables(frame.env) }); + } + return frames; + } + + /** An environment's own variables, minus runtime bookkeeping (function/class + * declarations, the bound `this`/`self`) that would clutter a variable table. */ + private snapshotFrameVariables(env: Environment): Record { + const variables: Record = {}; + for (const [name, value] of Object.entries(env.values)) { + if (name === 'this' || name === 'self') continue; + if (value && typeof value === 'object' && value.type === 'FunctionDeclaration') continue; + if (value instanceof JavaClass) continue; + variables[name] = value; + } + return variables; + } + + /** Builds the event yielded to the debugger for one step at `stmt`. */ + private debugEvent(stmt: Statement, overrides: Partial = {}): DebugStepEvent { + const callStack = this.getStackFrames(); + const globals = callStack[0].variables; + const locals = callStack[callStack.length - 1].variables; + return { + nodeId: stmt.id, + nodeType: stmt.type, + loc: stmt.loc || null, + variables: callStack.length > 1 ? { ...globals, ...locals } : globals, + callStack, + ...overrides, + }; + } + private *executeBlockGeneratorWithState( statements: Statement[], env: Environment - ): Generator< - { nodeId: string; nodeType: string; loc: any; variables: Record; prompt?: string }, - void, - void - > { - // console.log('executeBlockGeneratorWithState: Processing', statements.length, 'statements'); - let i = 0; - while (i < statements.length) { - const stmt = statements[i]; - // console.log('Processing statement type:', stmt.type, 'index:', i); + ): Generator { + const MAX_ITERATIONS = 10000; // Safety limit to prevent truly infinite loops + + for (const stmt of statements) { this.currentEnv = env; - // Handle control flow statements specially to yield steps for nested statements - if (stmt.type === 'If') { - const ifStmt = stmt as any; - // Yield the If statement itself (always, for debugging) - yield { - nodeId: ifStmt.id, - nodeType: ifStmt.type, - loc: ifStmt.loc || null, - variables: env.getAllVariables(), - }; - // Evaluate condition and execute appropriate branch - try { - const truthy = this.evaluate(ifStmt.condition, env); - if (truthy) { - yield* this.executeBlockGeneratorWithState(ifStmt.thenBranch.body, env); - } else if (ifStmt.elseBranch) { - yield* this.executeBlockGeneratorWithState(ifStmt.elseBranch.body, env); + switch (stmt.type) { + case 'If': { + // Announce the `if` line, then evaluate the condition (stepping into + // any user function calls it contains) and walk the taken branch. + yield this.debugEvent(stmt); + const condition = yield* this.evaluateForDebug(stmt.condition, env, stmt, false); + if (condition) { + yield* this.executeBlockGeneratorWithState(stmt.thenBranch.body, env); + } else if (stmt.elseBranch) { + yield* this.executeBlockGeneratorWithState(stmt.elseBranch.body, env); } - } catch (e) { - if (e instanceof ReturnException) throw e; - throw e; + break; } - i++; - } else if (stmt.type === 'While') { - const whileStmt = stmt as any; - let isFirstIteration = true; - let iterationCount = 0; - const MAX_ITERATIONS = 10000; // Safety limit to prevent truly infinite loops during testing - while (true) { - // Check iteration count to prevent actual infinite loops during execution - iterationCount++; - if (iterationCount > MAX_ITERATIONS) { - const lineNum = this.getLineFromLocation(whileStmt.loc); - throw new Error( - `runtime error occurred on line ${lineNum}:\nThis is probably an infinite loop.` - ); - } + case 'While': { + // The infinite-loop heuristic compares condition variables across the + // first iteration; a condition that calls functions can change without + // any variable changing (and re-evaluating it here would repeat the + // call's side effects), so it only applies to call-free conditions. + const heuristicApplies = !this.expressionContainsCall(stmt.condition); + let isFirstIteration = true; + let iterationCount = 0; - // Yield the While statement itself for each iteration (always, for debugging) - yield { - nodeId: whileStmt.id, - nodeType: whileStmt.type, - loc: whileStmt.loc || null, - variables: env.getAllVariables(), - }; - try { - const condition = this.evaluate(whileStmt.condition, env); + while (true) { + iterationCount++; + if (iterationCount > MAX_ITERATIONS) throw this.infiniteLoopError(stmt.loc); + + // Announce the `while` line for each condition check. + yield this.debugEvent(stmt); + const condition = yield* this.evaluateForDebug(stmt.condition, env, stmt, false); if (!condition) break; - // On first iteration, prepare for infinite loop detection let conditionVars: Set | null = null; let oldValues: Record | null = null; - if (isFirstIteration) { - conditionVars = this.extractVariablesFromExpression(whileStmt.condition); + if (isFirstIteration && heuristicApplies) { + conditionVars = this.extractVariablesFromExpression(stmt.condition); oldValues = {}; for (const varName of conditionVars) { try { oldValues[varName] = env.get(varName); } catch { - // Variable doesn't exist + // Variable doesn't exist yet } } } - // Execute one iteration - yield* this.executeBlockGeneratorWithState(whileStmt.body.body, env); + const signal = yield* this.runIterationGenerator(stmt.body.body, env); + if (signal === 'break') break; - // After first iteration, check if this might be an infinite loop - if (isFirstIteration && conditionVars !== null && oldValues !== null) { + if (isFirstIteration) { isFirstIteration = false; - - // Check if condition is still true and no variables changed - const conditionStillTrue = this.evaluate(whileStmt.condition, env); - const varsChanged = this.hasVariablesChanged(conditionVars, oldValues, env); - - // If condition is still true, variables haven't changed, and the body doesn't modify condition vars - if (conditionStillTrue && !varsChanged && conditionVars.size > 0) { - // Additional check: does the loop body modify any of these variables? - if (!this.blockModifiesVariables(whileStmt.body.body, conditionVars, env)) { - const lineNum = this.getLineFromLocation(whileStmt.loc); - throw new Error( - `runtime error occurred on line ${lineNum}:\nThis is probably an infinite loop.` - ); + // Run the heuristic only when the body ran to completion (a + // continue iteration is inconclusive). + if (signal === 'normal' && conditionVars && oldValues && conditionVars.size > 0) { + const conditionStillTrue = this.evaluate(stmt.condition, env); + const varsChanged = this.hasVariablesChanged(conditionVars, oldValues, env); + if ( + conditionStillTrue && + !varsChanged && + !this.blockModifiesVariables(stmt.body.body, conditionVars, env) + ) { + throw this.infiniteLoopError(stmt.loc); } } } - } catch (e) { - if (e instanceof ReturnException) throw e; - throw e; } + break; } - i++; - } else if (stmt.type === 'For') { - const forStmt = stmt as any; - try { + + case 'For': { // C-style for loop; any clause may be absent (`for (;;)`). - if (forStmt.init) this.execute(forStmt.init, env); + if (stmt.init) this.execute(stmt.init, env); + let iterationCount = 0; while (true) { - // Yield the For statement itself for each iteration (always, for debugging) - yield { - nodeId: forStmt.id, - nodeType: forStmt.type, - loc: forStmt.loc || null, - variables: env.getAllVariables(), - }; - if (forStmt.condition && !this.evaluate(forStmt.condition, env)) break; - yield* this.executeBlockGeneratorWithState(forStmt.body.body, env); - if (forStmt.update) this.execute(forStmt.update, env); + iterationCount++; + if (iterationCount > MAX_ITERATIONS) throw this.infiniteLoopError(stmt.loc); + + yield this.debugEvent(stmt); + if (stmt.condition) { + const condition = yield* this.evaluateForDebug(stmt.condition, env, stmt, false); + if (!condition) break; + } + const signal = yield* this.runIterationGenerator(stmt.body.body, env); + if (signal === 'break') break; + // `continue` still runs the update clause (C semantics). + if (stmt.update) this.execute(stmt.update, env); } - } catch (e) { - if (e instanceof ReturnException) throw e; - throw e; + break; } - i++; - } else if (stmt.type === 'ForEach') { - const forStmt = stmt as any; - try { - const iterable = this.evaluate(forStmt.iterable, env); + + case 'ForEach': { + const iterable = yield* this.evaluateForDebug(stmt.iterable, env, stmt); if (!Array.isArray(iterable) && typeof iterable !== 'string') { throw new Error('For-each loop requires array or string'); } for (const item of iterable) { - env.define(forStmt.variable, item); - // Yield the ForEach statement itself for each iteration (always, for debugging) - yield { - nodeId: forStmt.id, - nodeType: forStmt.type, - loc: forStmt.loc || null, - variables: env.getAllVariables(), - }; - yield* this.executeBlockGeneratorWithState(forStmt.body.body, env); + env.define(stmt.variable, item); + yield this.debugEvent(stmt); + if ((yield* this.runIterationGenerator(stmt.body.body, env)) === 'break') break; } - } catch (e) { - if (e instanceof ReturnException) throw e; - throw e; + break; } - i++; - } else if (stmt.type === 'DoWhile') { - const doWhileStmt = stmt as any; - let iterationCount = 0; - const MAX_ITERATIONS = 10000; - do { - iterationCount++; - if (iterationCount > MAX_ITERATIONS) { - const lineNum = this.getLineFromLocation(doWhileStmt.loc); - throw new Error( - `runtime error occurred on line ${lineNum}:\nThis is probably an infinite loop.` - ); - } + case 'DoWhile': { + let iterationCount = 0; + while (true) { + iterationCount++; + if (iterationCount > MAX_ITERATIONS) throw this.infiniteLoopError(stmt.loc); - yield { - nodeId: doWhileStmt.id, - nodeType: doWhileStmt.type, - loc: doWhileStmt.loc || null, - variables: env.getAllVariables(), - }; - try { - yield* this.executeBlockGeneratorWithState(doWhileStmt.body.body, env); - } catch (e) { - if (e instanceof ReturnException) throw e; - throw e; + yield this.debugEvent(stmt); + if ((yield* this.runIterationGenerator(stmt.body.body, env)) === 'break') break; + const condition = yield* this.evaluateForDebug(stmt.condition, env, stmt, false); + if (!condition) break; } - } while (this.evaluate(doWhileStmt.condition, env)); + break; + } - i++; - } else if (stmt.type === 'RepeatUntil') { - // Post-condition loop: body runs first, stops when condition becomes TRUE. - const repeatStmt = stmt as any; - let iterationCount = 0; - const MAX_ITERATIONS = 10000; + case 'RepeatUntil': { + // Post-condition loop: body runs first, stops when condition becomes TRUE. + let iterationCount = 0; + while (true) { + iterationCount++; + if (iterationCount > MAX_ITERATIONS) throw this.infiniteLoopError(stmt.loc); - do { - iterationCount++; - if (iterationCount > MAX_ITERATIONS) { - const lineNum = this.getLineFromLocation(repeatStmt.loc); - throw new Error( - `runtime error occurred on line ${lineNum}:\nThis is probably an infinite loop.` - ); + yield this.debugEvent(stmt); + if ((yield* this.runIterationGenerator(stmt.body.body, env)) === 'break') break; + const condition = yield* this.evaluateForDebug(stmt.condition, env, stmt, false); + if (condition) break; } + break; + } - yield { - nodeId: repeatStmt.id, - nodeType: repeatStmt.type, - loc: repeatStmt.loc || null, - variables: env.getAllVariables(), - }; - try { - yield* this.executeBlockGeneratorWithState(repeatStmt.body.body, env); - } catch (e) { - if (e instanceof ReturnException) throw e; - throw e; - } - } while (!this.evaluate(repeatStmt.condition, env)); + case 'Return': { + const value = yield* this.evaluateForDebug(stmt.value, env, stmt); + // Show the `return` line while the function's locals are still alive; + // the next step lands back at the call site with this frame gone. + yield this.debugEvent(stmt); + throw new ReturnException(value); + } - i++; - } else { - // --- Step-into user-defined function calls --- - - if (stmt.type === 'ExpressionStatement') { - const callInfo = this.getUserFunctionCall((stmt as any).expression, env); - if (callInfo) { - yield { - nodeId: stmt.id, - nodeType: stmt.type, - loc: stmt.loc || null, - variables: env.getAllVariables(), - }; - yield* this.callUserFunctionWithState(callInfo.func, callInfo.args, env); - i++; - continue; - } - } else if (stmt.type === 'Assignment') { - const stmtValue = (stmt as any).value; - const varName = lvalueName(stmt as any) ?? null; - - // Direct user function call: score <- askQ(...) - let callInfo = this.getUserFunctionCall(stmtValue, env); - let binaryOp: string | null = null; - let binaryOtherSide: any = null; - let funcOnRight = false; - - // Binary expression with a user function call on one side: score <- score + askQ(...) - if (!callInfo && stmtValue?.type === 'BinaryExpression') { - const rightInfo = this.getUserFunctionCall(stmtValue.right, env); - if (rightInfo) { - callInfo = rightInfo; - binaryOp = stmtValue.operator; - binaryOtherSide = stmtValue.left; - funcOnRight = true; - } else { - const leftInfo = this.getUserFunctionCall(stmtValue.left, env); - if (leftInfo) { - callInfo = leftInfo; - binaryOp = stmtValue.operator; - binaryOtherSide = stmtValue.right; - funcOnRight = false; - } - } - } + case 'Break': + yield this.debugEvent(stmt); + throw new BreakException(); - if (callInfo && varName) { - yield { - nodeId: stmt.id, - nodeType: stmt.type, - loc: stmt.loc || null, - variables: env.getAllVariables(), - }; - const funcResult = yield* this.callUserFunctionWithState( - callInfo.func, - callInfo.args, - env - ); - let assignValue: any; - if (binaryOp !== null) { - const other = this.evaluate(binaryOtherSide, env); - const l = funcOnRight ? other : funcResult; - const r = funcOnRight ? funcResult : other; - switch (binaryOp) { - case '+': - assignValue = l + r; - break; - case '-': - assignValue = l - r; - break; - case '*': - assignValue = l * r; - break; - case '/': - assignValue = - this.isIntegerType(env.getType(varName)) && r !== 0 ? Math.trunc(l / r) : l / r; - break; - case '%': - assignValue = l % r; - break; - default: - assignValue = l + r; + case 'Continue': + yield this.debugEvent(stmt); + throw new ContinueException(); + + case 'BlankLine': + break; // no runtime effect — not worth a debugger step + + default: { + // Step into any user function calls in the statement's expressions + // first; their results are cached so executing the statement below + // doesn't run them a second time. + const cache = new Map(); + this.debugCallResults.push(cache); + try { + for (const expr of this.statementExpressions(stmt)) { + yield* this.resolveUserCalls(expr, env, stmt, cache, true); + } + // Execute, pausing (and later retrying) when console input is needed. + while (true) { + try { + this.execute(stmt, env); + break; + } catch (e) { + if (!(e instanceof InputPrompt)) throw e; + yield this.debugEvent(stmt, { nodeType: 'InputPrompt', prompt: e.prompt }); } - } else { - assignValue = funcResult; } - env.define(varName, assignValue, (stmt as any).varType, stmt.loc?.start); - i++; - continue; - } - } else if (stmt.type === 'Return' && (stmt as any).value) { - const callInfo = this.getUserFunctionCall((stmt as any).value, env); - if (callInfo) { - yield { - nodeId: stmt.id, - nodeType: stmt.type, - loc: stmt.loc || null, - variables: env.getAllVariables(), - }; - const returnValue = yield* this.callUserFunctionWithState( - callInfo.func, - callInfo.args, - env - ); - throw new ReturnException(returnValue); + } finally { + this.debugCallResults.pop(); } + yield this.debugEvent(stmt); } + } + } + } - // For all other statements, execute and yield - let needsRetry = false; - let lastError: any = null; + /** Runs one loop-body iteration in debug mode, translating break/continue + * signals into a return code (the generator twin of runIteration). */ + private *runIterationGenerator( + body: Statement[], + env: Environment + ): Generator { + try { + yield* this.executeBlockGeneratorWithState(body, env); + return 'normal'; + } catch (e) { + if (e instanceof ContinueException) return 'continue'; + if (e instanceof BreakException) return 'break'; + throw e; + } + } - try { - this.execute(stmt, env); - } catch (e) { - if (e instanceof InputPrompt) { - needsRetry = true; - lastError = e; - } else if (e instanceof ReturnException) { - throw e; - } else { - throw e; - } - } + /** The expressions a statement evaluates directly — the places a user function + * call the debugger should step into can appear. */ + private statementExpressions(stmt: Statement): Expression[] { + switch (stmt.type) { + case 'Print': + return stmt.expressions; + case 'Assignment': + // Value first, then the target (matching execute's evaluation order — + // the target only holds expressions for member/index assignments). + return [stmt.value, stmt.target as Expression]; + case 'ExpressionStatement': + return [stmt.expression]; + case 'Switch': + return [(stmt as any).discriminant]; + default: + return []; + } + } - // Yield the result (either success or InputPrompt) - yield { - nodeId: stmt.id, - nodeType: needsRetry ? 'InputPrompt' : stmt.type, - loc: stmt.loc || null, - variables: env.getAllVariables(), - prompt: needsRetry ? lastError?.prompt || '' : undefined, - }; + /** + * Evaluates an expression during debugging: first steps through any user + * function calls inside it (caching their results so the final evaluation + * doesn't re-run them), then evaluates the whole expression, pausing and + * retrying when it needs console input. `announceCallSites` yields an extra + * step at `stmt` before entering each call — control-flow statements that + * already announced their own line pass false. + */ + private *evaluateForDebug( + expr: Expression | undefined, + env: Environment, + stmt: Statement, + announceCallSites = true + ): Generator { + if (!expr) return null; + const cache = new Map(); + this.debugCallResults.push(cache); + try { + yield* this.resolveUserCalls(expr, env, stmt, cache, announceCallSites); + return yield* this.evaluateWithInputRetry(expr, env, stmt); + } finally { + this.debugCallResults.pop(); + } + } - // If we need input, don't increment - next iteration will retry - if (!needsRetry) { - i++; - } - // If InputPrompt, DON'T increment i - next call to generator.next() will re-enter this try block + /** Evaluates during debugging, yielding an input-prompt step and retrying + * whenever the expression asks for console input that isn't queued yet. */ + private *evaluateWithInputRetry( + expr: Expression, + env: Environment, + stmt: Statement + ): Generator { + while (true) { + try { + return this.evaluate(expr, env); + } catch (e) { + if (!(e instanceof InputPrompt)) throw e; + yield this.debugEvent(stmt, { nodeType: 'InputPrompt', prompt: e.prompt }); } } } /** - * Returns the FunctionDeclaration and evaluated args if expr is a direct call to a - * user-defined function, otherwise returns null. + * Walks an expression in evaluation order and steps through every user-written + * function/method call in it, caching each call's result by node id so the + * later "real" evaluation (which consults the cache via debugCallResults) + * reuses the results instead of running the calls again. */ - private getUserFunctionCall( + private *resolveUserCalls( expr: any, - env: Environment - ): { func: FunctionDeclaration; args: any[] } | null { - if (!expr || expr.type !== 'CallExpression') return null; - if (expr.callee?.type !== 'Identifier') return null; - const calleeName = expr.callee.name; - try { - const callee = env.get(calleeName); - if (!callee || callee.type !== 'FunctionDeclaration') return null; - const func = callee as FunctionDeclaration; - const args = expr.arguments.map((a: any) => this.evaluate(a, env)); - return { func, args }; - } catch { + env: Environment, + stmt: Statement, + cache: Map, + announceCallSites: boolean + ): Generator { + if (!expr || typeof expr !== 'object') return; + + switch (expr.type) { + case 'CallExpression': { + // Arguments evaluate first, so calls inside them are stepped first. + for (const arg of expr.arguments ?? []) { + yield* this.resolveUserCalls(arg, env, stmt, cache, announceCallSites); + } + const target = this.resolveUserCallable(expr, env); + if (!target) return; // built-in or unresolvable — evaluate runs it normally + if (announceCallSites) yield this.debugEvent(stmt); + const args: any[] = []; + for (const arg of expr.arguments ?? []) { + args.push(yield* this.evaluateWithInputRetry(arg, env, stmt)); + } + cache.set(expr.id, yield* this.stepIntoCall(target, args, env)); + return; + } + case 'ConditionalExpression': { + // Mirror evaluate's laziness: only the taken branch runs. + yield* this.resolveUserCalls(expr.test, env, stmt, cache, announceCallSites); + const test = yield* this.evaluateWithInputRetry(expr.test, env, stmt); + const branch = test ? expr.consequent : expr.alternate; + yield* this.resolveUserCalls(branch, env, stmt, cache, announceCallSites); + return; + } + case 'Assignment': // expression-position assignment (e.g. a for-update `x = f(y)`) + case 'CompoundAssignment': + yield* this.resolveUserCalls(expr.value ?? expr.right, env, stmt, cache, announceCallSites); + return; + case 'BinaryExpression': // `and`/`or` are eager in evaluate, so both sides always run + yield* this.resolveUserCalls(expr.left, env, stmt, cache, announceCallSites); + yield* this.resolveUserCalls(expr.right, env, stmt, cache, announceCallSites); + return; + case 'UnaryExpression': + yield* this.resolveUserCalls(expr.argument, env, stmt, cache, announceCallSites); + return; + case 'IndexExpression': + yield* this.resolveUserCalls(expr.object, env, stmt, cache, announceCallSites); + yield* this.resolveUserCalls(expr.index, env, stmt, cache, announceCallSites); + return; + case 'MemberExpression': + yield* this.resolveUserCalls(expr.object, env, stmt, cache, announceCallSites); + return; + case 'ArrayLiteral': + for (const element of expr.elements ?? []) { + yield* this.resolveUserCalls(element, env, stmt, cache, announceCallSites); + } + return; + case 'NewExpression': // constructor bodies run without stepping, but their args may call + for (const arg of expr.arguments ?? []) { + yield* this.resolveUserCalls(arg, env, stmt, cache, announceCallSites); + } + return; + default: + return; // leaves (Literal, Identifier, ...) — nothing to step into + } + } + + /** + * Resolves a CallExpression to a user-written function or method the debugger + * can step into. Returns null for built-ins and for anything that can't be + * identified without side effects — those calls run normally in `evaluate`. + * Dispatch order deliberately mirrors evaluate's CallExpression handling. + */ + private resolveUserCallable(expr: any, env: Environment): ResolvedCallable | null { + const callee = expr.callee; + + if (callee?.type === 'Identifier') { + if (BUILTIN_CALL_NAMES.has(callee.name)) return null; + let target: any; + try { + target = env.get(callee.name); + } catch { + target = undefined; + } + if (target && target.type === 'FunctionDeclaration') { + return { name: callee.name, decl: target as FunctionDeclaration }; + } + if (NUMERIC_BUILTIN_NAMES.has(callee.name)) return null; + // Bare call to a sibling method, e.g. a Main static method called from main(). + const instance = this.boundInstance(env); + const method = instance?.klass.getMethod(callee.name); + if (instance && method) return { name: callee.name, decl: method, thisInstance: instance }; return null; } + + // obj.method(...) — only for side-effect-free receivers (a bare name or + // this/self), since detecting the method requires evaluating the receiver. + if ( + callee?.type === 'MemberExpression' && + (callee.object?.type === 'Identifier' || callee.object?.type === 'ThisExpression') + ) { + let receiver: any; + try { + receiver = this.evaluate(callee.object, env); + } catch { + return null; // e.g. `Math.floor(...)` — Math isn't a variable + } + if (!(receiver instanceof JavaInstance)) return null; + const method = receiver.klass.getMethod(callee.property?.name); + if (!method) return null; + return { name: callee.property.name, decl: method, thisInstance: receiver }; + } + + return null; } /** - * Generator that steps through a user-defined function body one statement at a time. - * Catches ReturnException and returns the value as the generator's return value so the - * caller can use it (e.g. for assignments). + * Steps through the body of a user-written function or method, giving it its + * own stack frame, and returns the call's return value. */ - private *callUserFunctionWithState( - func: FunctionDeclaration, + private *stepIntoCall( + target: ResolvedCallable, args: any[], - parentEnv: Environment - ): Generator< - { nodeId: string; nodeType: string; loc: any; variables: Record; prompt?: string }, - any, - void - > { - const fnEnv = new Environment(parentEnv); - func.params.forEach((param, idx) => fnEnv.define(param.name, args[idx] ?? undefined)); + callerEnv: Environment + ): Generator { + const fnEnv = new Environment(callerEnv); + if (target.thisInstance) { + fnEnv.define('this', target.thisInstance); + fnEnv.define('self', target.thisInstance); + } + this.bindParams(target.decl.params, args, fnEnv); + this.debugCallStack.push({ name: target.name, env: fnEnv }); try { - yield* this.executeBlockGeneratorWithState(func.body.body, fnEnv); + yield* this.executeBlockGeneratorWithState(target.decl.body.body, fnEnv); return null; } catch (e) { - if (e instanceof ReturnException) return (e as ReturnException).value; + if (e instanceof ReturnException) return e.value; throw e; + } finally { + this.debugCallStack.pop(); + } + } + + private infiniteLoopError(loc: any): Error { + const lineNum = this.getLineFromLocation(loc); + return new Error( + `runtime error occurred on line ${lineNum}:\nThis is probably an infinite loop.` + ); + } + + /** True when the expression contains any call or instantiation. */ + private expressionContainsCall(expr: any): boolean { + if (!expr || typeof expr !== 'object') return false; + if (expr.type === 'CallExpression' || expr.type === 'NewExpression') return true; + for (const key of Object.keys(expr)) { + if (key === 'loc') continue; + const child = expr[key]; + if (Array.isArray(child)) { + if (child.some((c) => this.expressionContainsCall(c))) return true; + } else if (this.expressionContainsCall(child)) { + return true; + } } + return false; } private registerClass(classDecl: ClassDeclaration) { @@ -1844,6 +2015,13 @@ export class Interpreter { throw new Error(`Cannot access member on non-object`); case 'CallExpression': + // Debug stepping may already have run this call (stepping through its + // body) and cached the result — reuse it instead of calling twice. + const steppedCalls = this.debugCallResults[this.debugCallResults.length - 1]; + if (steppedCalls?.has(expr.id)) { + return steppedCalls.get(expr.id); + } + if ((expr.callee as any).type === 'MemberExpression') { const memberExpr = expr.callee as any; diff --git a/src/language/java/emitter.ts b/src/language/java/emitter.ts index c020330..9e21654 100644 --- a/src/language/java/emitter.ts +++ b/src/language/java/emitter.ts @@ -660,7 +660,7 @@ export class JavaEmitter extends ASTVisitor { // Unroll nested if blocks into else-if chains for compact syntax while (currentElse && currentElse.body.length === 1 && currentElse.body[0].type === 'If') { const elifStmt = currentElse.body[0]; - this.emit(`} else if (${this.generateExpression(elifStmt.condition, 0)}) {`); + this.emit(`} else if (${this.generateExpression(elifStmt.condition, 0)}) {`, elifStmt.id); this.indent(); this.context.symbolTable.enterScope(); this.visitBlock(elifStmt.thenBranch); @@ -701,7 +701,7 @@ export class JavaEmitter extends ASTVisitor { * Body guaranteed to execute at least once. */ visitDoWhile(stmt: any): void { - this.emit(`do {`); + this.emit(`do {`, stmt.id); this.indent(); this.context.symbolTable.enterScope(); this.visitBlock(stmt.body); @@ -730,7 +730,7 @@ export class JavaEmitter extends ASTVisitor { * Avoids breaks only when already present or for final case. */ visitSwitch(stmt: any): void { - this.emit(`switch (${this.generateExpression(stmt.discriminant, 0)}) {`); + this.emit(`switch (${this.generateExpression(stmt.discriminant, 0)}) {`, stmt.id); this.indent(); this.context.symbolTable.enterScope(); @@ -770,15 +770,15 @@ export class JavaEmitter extends ASTVisitor { /** * Emits break statement to exit loop or switch. */ - visitBreak(_stmt: any): void { - this.emit('break;'); + visitBreak(stmt: any): void { + this.emit('break;', stmt.id); } /** * Emits continue statement to skip to next loop iteration. */ - visitContinue(_stmt: any): void { - this.emit('continue;'); + visitContinue(stmt: any): void { + this.emit('continue;', stmt.id); } /** @@ -932,7 +932,8 @@ export class JavaEmitter extends ASTVisitor { else if (this.isArrayListType(iterType)) varType = this.getArrayListElementType(iterType); this.emit( - `for (${varType} ${stmt.variable} : ${this.generateExpression(stmt.iterable, 0)}) {` + `for (${varType} ${stmt.variable} : ${this.generateExpression(stmt.iterable, 0)}) {`, + stmt.id ); // A for-each header always declares its own variable (Java requires the // type), but record it so a later re-declaration drops its type. @@ -1009,7 +1010,7 @@ export class JavaEmitter extends ASTVisitor { * Supports optional finally block. */ visitTry(stmt: any): void { - this.emit('try {'); + this.emit('try {', stmt.id); this.indent(); this.visitBlock(stmt.body); this.dedent(); diff --git a/src/language/javascript/emitter.ts b/src/language/javascript/emitter.ts index 78e34c7..451d16b 100644 --- a/src/language/javascript/emitter.ts +++ b/src/language/javascript/emitter.ts @@ -252,7 +252,7 @@ export class JavaScriptEmitter extends ASTVisitor { let current = stmt.elseBranch; while (current && current.body.length === 1 && current.body[0].type === 'If') { const elif = current.body[0]; - this.emit(`} else if (${this.generateExpression(elif.condition, 0)}) {`); + this.emit(`} else if (${this.generateExpression(elif.condition, 0)}) {`, elif.id); this.indent(); this.visitBlock(elif.thenBranch); this.dedent(); @@ -278,7 +278,7 @@ export class JavaScriptEmitter extends ASTVisitor { } visitDoWhile(stmt: any): void { - this.emit('do {'); + this.emit('do {', stmt.id); this.indent(); this.visitBlock(stmt.body); this.dedent(); @@ -310,11 +310,11 @@ export class JavaScriptEmitter extends ASTVisitor { this.emit('}'); } - visitBreak(_stmt: any): void { - this.emit('break;'); + visitBreak(stmt: any): void { + this.emit('break;', stmt.id); } - visitContinue(_stmt: any): void { - this.emit('continue;'); + visitContinue(stmt: any): void { + this.emit('continue;', stmt.id); } visitFor(stmt: For): void { @@ -425,7 +425,7 @@ export class JavaScriptEmitter extends ASTVisitor { } visitTry(stmt: any): void { - this.emit('try {'); + this.emit('try {', stmt.id); this.indent(); this.visitBlock(stmt.body); this.dedent(); diff --git a/src/language/praxis/emitter.ts b/src/language/praxis/emitter.ts index 678ba5f..f8bc528 100644 --- a/src/language/praxis/emitter.ts +++ b/src/language/praxis/emitter.ts @@ -393,7 +393,7 @@ export class PraxisEmitter extends ASTVisitor { } visitDoWhile(stmt: any): void { - this.emit(`do`); + this.emit(`do`, stmt.id); this.indent(); this.context.symbolTable.enterScope(); this.visitBlock(stmt.body); @@ -445,11 +445,11 @@ export class PraxisEmitter extends ASTVisitor { this.emit('end if'); } - visitBreak(_stmt: any): void { - this.emit('break'); + visitBreak(stmt: any): void { + this.emit('break', stmt.id); } - visitContinue(_stmt: any): void { - this.emit('continue'); + visitContinue(stmt: any): void { + this.emit('continue', stmt.id); } visitFor(stmt: any): void { @@ -566,7 +566,7 @@ export class PraxisEmitter extends ASTVisitor { } visitTry(stmt: any): void { - this.emit('try'); + this.emit('try', stmt.id); this.indent(); this.visitBlock(stmt.body); this.dedent(); diff --git a/src/language/python/emitter.ts b/src/language/python/emitter.ts index fe4815b..5c9f2dd 100644 --- a/src/language/python/emitter.ts +++ b/src/language/python/emitter.ts @@ -401,7 +401,7 @@ export class PythonEmitter extends ASTVisitor { while (currentElse && currentElse.body.length === 1 && currentElse.body[0].type === 'If') { const elifStmt = currentElse.body[0]; - this.emit(`elif ${this.generateExpression(elifStmt.condition, 0)}:`); + this.emit(`elif ${this.generateExpression(elifStmt.condition, 0)}:`, elifStmt.id); this.indent(); this.visitBlock(elifStmt.thenBranch); this.dedent(); @@ -431,7 +431,7 @@ export class PythonEmitter extends ASTVisitor { * Python lacks do-while, so implements as while True with break condition. */ visitDoWhile(stmt: any): void { - this.emit(`while True:`); + this.emit(`while True:`, stmt.id); this.indent(); this.visitBlock(stmt.body); this.emit(`if not (${this.generateExpression(stmt.condition, 0)}):`); @@ -469,10 +469,13 @@ export class PythonEmitter extends ASTVisitor { stmt.cases.forEach((caseStmt: any) => { if (caseStmt.test) { const keyword = first ? 'if' : 'elif'; - this.emit(`${keyword} ${discriminant} == ${this.generateExpression(caseStmt.test, 0)}:`); + this.emit( + `${keyword} ${discriminant} == ${this.generateExpression(caseStmt.test, 0)}:`, + first ? stmt.id : undefined + ); first = false; } else { - this.emit(`else:`); + this.emit(`else:`, first ? stmt.id : undefined); } this.indent(); let emittedBody = false; @@ -489,15 +492,15 @@ export class PythonEmitter extends ASTVisitor { /** * Visits break and returns the result. */ - visitBreak(_stmt: any): void { - this.emit('break'); + visitBreak(stmt: any): void { + this.emit('break', stmt.id); } /** * Visits continue and returns the result. */ - visitContinue(_stmt: any): void { - this.emit('continue'); + visitContinue(stmt: any): void { + this.emit('continue', stmt.id); } /** @@ -639,7 +642,7 @@ export class PythonEmitter extends ASTVisitor { * Supports multiple except blocks and optional finally cleanup block. */ visitTry(stmt: any): void { - this.emit('try:'); + this.emit('try:', stmt.id); this.indent(); this.visitBlock(stmt.body); this.dedent(); diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index 206ef9c..a230a73 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -150,6 +150,7 @@ export default function EditorPage() { highlightedSourceLines, setHighlightedSourceLines, currentVariables, + currentCallStack, waitingForInput, inputPrompt, initDebugger, @@ -599,7 +600,7 @@ export default function EditorPage() { ast, panels, getTranslation, - result.step?.sourceLocation || null + result.step?.nodeId ); setPanelHighlightedLines(panelHighlights); setOutput(result.outputLines); @@ -639,10 +640,11 @@ export default function EditorPage() { ast, panels, getTranslation, - result.step?.sourceLocation || null + result.step?.nodeId ); setPanelHighlightedLines(panelHighlights); - setOutput((prev) => [...prev, ...result.outputLines]); + // outputLines is the cumulative program output, so replace rather than append. + setOutput(result.outputLines); if (result.isComplete) { setIsDebugComplete(true); @@ -1272,6 +1274,7 @@ export default function EditorPage() { error={error} hasRun={hasRun} variables={currentVariables} + callStack={currentCallStack} showVariables={isDebugging} height={outputState === 'open' ? outputHeight : undefined} resizeActive={resizingIdx === 'output'} diff --git a/src/pages/EmbedPage.tsx b/src/pages/EmbedPage.tsx index 22fbabf..c2e0e46 100644 --- a/src/pages/EmbedPage.tsx +++ b/src/pages/EmbedPage.tsx @@ -20,6 +20,7 @@ import { highlightedLinesField, dispatchLineHighlighting } from '../utils/codemi import { useCodeParsing } from '../hooks/useCodeParsing'; import { useCodeDebugger } from '../hooks/useCodeDebugger'; import { Debugger } from '../language/debugger'; +import { VariableFrames } from '../components/VariableFrames'; const VALID_TO_LANGS = ['python', 'java', 'csp', 'praxis', 'javascript', 'ast']; @@ -57,7 +58,7 @@ export default function EmbedPage() { setHighlightedSourceLines, highlightedTranslationLines, setHighlightedTranslationLines, - currentVariables, + currentCallStack, waitingForInput, inputPrompt, initDebugger, @@ -232,7 +233,8 @@ export default function EmbedPage() { setHighlightedSourceLines(result.sourceHighlightedLines); setHighlightedTranslationLines(result.translationHighlightedLines); - setOutput((prev) => [...prev, ...result.outputLines]); + // outputLines is the cumulative program output, so replace rather than append. + setOutput(result.outputLines); if (result.isComplete) { setIsDebugComplete(true); @@ -407,18 +409,23 @@ export default function EmbedPage() { Error: {error}
)} - {isDebugging && Object.keys(currentVariables).length > 0 && ( + {isDebugging && currentCallStack.length > 0 && (
-
Variables:
- {Object.entries(currentVariables).map(([key, value]) => ( -
- {key}: {JSON.stringify(value)} -
- ))} +
+ Variables + {currentCallStack.length > 1 && ( + + — in {currentCallStack[currentCallStack.length - 1].name}() + + )} +
+
)} {output.length === 0 && !error ? ( -
Run code to see output...
+
+ {isDebugging ? 'No output yet — keep stepping…' : 'Run code to see output...'} +
) : ( output.map((line, idx) => (
diff --git a/src/utils/debugHandlers.ts b/src/utils/debugHandlers.ts index 7fd133c..32217d9 100644 --- a/src/utils/debugHandlers.ts +++ b/src/utils/debugHandlers.ts @@ -1,49 +1,14 @@ /** - * Debug step computation functions that execute code and compute debug information. - * Handles both simple execution and step-by-step debugging with output and variable tracking. + * Debug highlighting helpers shared by the editor pages. */ import type { Program } from '../language/ast'; import type { SupportedLang } from '../components/LanguageSelector'; -import { Interpreter } from '../language/interpreter'; -import { findNodesAtLocation } from './debuggerUtils'; import type { SourceMap } from '../hooks/useCodeParsing'; /** - * Result of a debug step operation with all data needed by UI - */ -export interface DebugStepData { - sourceHighlightedLines: number[]; - panelHighlights: Map; // For EditorPage multi-panel - outputLines: string[]; - isComplete: boolean; -} - -/** - * Execute code and return output (shared between both pages) - */ -export function computeRunOutput(ast: Program | null, sourceCode: string = ''): string[] { - const output: string[] = []; - if (!ast) return output; - - try { - const interpreter = new Interpreter(); - const results = interpreter.interpret(ast, sourceCode); - output.push(...results); - } catch (e: any) { - // Re-throw InputPrompt errors so UI can handle input - if (e.name === 'InputPrompt') { - throw e; - } - output.push(`Error: ${e.message}`); - } - - return output; -} - -/** - * Process a debug step for multi-panel highlighting (EditorPage-specific) - * Computes which lines to highlight in each language panel + * Computes which line to highlight in each open translation panel for the + * debug step at `nodeId`, using each panel's emitter source map. */ export function computeMultiplePanelHighlighting( ast: Program | null, @@ -52,35 +17,17 @@ export function computeMultiplePanelHighlighting( ast: Program | null, lang: SupportedLang ) => { code: string; sourceMap: SourceMap }, - sourceLocation: { start: number; end: number } | null + nodeId: string | null | undefined ): Map { const panelHighlights = new Map(); - if (!ast || !sourceLocation || panels.length === 0) { + if (!ast || !nodeId || panels.length === 0) { return panelHighlights; } - const nodesAtLocation = findNodesAtLocation(ast, sourceLocation.start); - const nodeIds = nodesAtLocation.map((n) => n.id); - for (const panel of panels) { - const translation = getTranslation(ast, panel.lang); - const panelHighlightSet = new Set(); - - for (const nodeId of nodeIds) { - const mapEntry = translation.sourceMap.get - ? translation.sourceMap.get(nodeId) - : (translation.sourceMap as any)[nodeId]; - - if (mapEntry !== undefined) { - if (typeof mapEntry === 'object' && 'lineStart' in mapEntry) { - panelHighlightSet.add(mapEntry.lineStart - 1); - } else if (typeof mapEntry === 'number') { - panelHighlightSet.add(mapEntry); - } - } - } - panelHighlights.set(panel.id, Array.from(panelHighlightSet)); + const line = getTranslation(ast, panel.lang).sourceMap.get(nodeId); + panelHighlights.set(panel.id, line !== undefined ? [line] : []); } return panelHighlights; diff --git a/src/utils/debuggerUtils.ts b/src/utils/debuggerUtils.ts index cb77651..1178086 100644 --- a/src/utils/debuggerUtils.ts +++ b/src/utils/debuggerUtils.ts @@ -1,11 +1,7 @@ /** - * Debugging utility functions for mapping source code locations to AST nodes. - * Provides line number calculation and node finding for debug highlighting. + * Debugging utility functions for mapping source code locations to editor lines. */ -import type { Program, ASTNode } from '../language/ast'; -import type { SourceMap } from '../language/visitor'; - /** * Get the line number (1-based) for a character offset */ @@ -21,45 +17,3 @@ export function getRangeLines(code: string, start: number): number[] { const startLine = getLineNumber(code, start); return [startLine - 1]; // Convert to 0-based for CodeMirror } - -/** - * Find all AST nodes that span the given source location - */ -export function findNodesAtLocation(program: Program, sourceStart: number): ASTNode[] { - const foundNodes: ASTNode[] = []; - - const traverse = (node: any): void => { - if (!node || typeof node !== 'object') return; - - // Check if this node contains the source location - if (node.loc && node.loc.start <= sourceStart && sourceStart < node.loc.end) { - if (node.id && node.type) { - foundNodes.push(node); - } - } - - // Traverse children - if (Array.isArray(node)) { - node.forEach(traverse); - } else { - Object.values(node).forEach(traverse); - } - }; - - traverse(program); - return foundNodes; -} - -/** - * Get highlighted line numbers from AST node IDs using a source map - */ -export function getHighlightedLinesFromNodeIds(nodeIds: string[], sourceMap: SourceMap): number[] { - const lines = new Set(); - for (const nodeId of nodeIds) { - const lineIndex = sourceMap.get(nodeId); - if (lineIndex !== undefined) { - lines.add(lineIndex); - } - } - return Array.from(lines); -} diff --git a/tests/debugger.test.ts b/tests/debugger.test.ts new file mode 100644 index 0000000..b12a333 --- /dev/null +++ b/tests/debugger.test.ts @@ -0,0 +1,399 @@ +/** + * Debugger step-through tests: stepping into user-defined functions/methods, + * per-frame variable state, and control flow while debugging. + */ + +import { describe, it, expect } from 'vitest'; +import { PraxisLexer } from '../src/language/praxis/lexer'; +import { PraxisParser } from '../src/language/praxis/parser'; +import { Lexer as PythonLexer } from '../src/language/python/lexer'; +import { Parser as PythonParser } from '../src/language/python/parser'; +import { JavaLexer } from '../src/language/java/lexer'; +import { JavaParser } from '../src/language/java/parser'; +import { Debugger, type DebugStep, type SupportedLang } from '../src/language/debugger'; +import { Translator } from '../src/language/translator'; +import type { Program } from '../src/language/ast'; + +function parse(lang: SupportedLang, code: string): Program { + switch (lang) { + case 'praxis': + return new PraxisParser(new PraxisLexer(code).tokenize()).parse(); + case 'python': + return new PythonParser(new PythonLexer(code).tokenize()).parse(); + case 'java': + return new JavaParser(new JavaLexer(code).tokenize()).parse(); + default: + throw new Error(`unsupported test language ${lang}`); + } +} + +/** Runs the debugger to completion (or `maxSteps`) and returns every step. */ +function collectSteps(lang: SupportedLang, code: string, maxSteps = 200): DebugStep[] { + const dbg = new Debugger(); + dbg.init(parse(lang, code), lang, code); + const steps: DebugStep[] = []; + for (let i = 0; i < maxSteps; i++) { + const step = dbg.step(); + if (!step) break; + steps.push(step); + if (step.isComplete) break; + } + return steps; +} + +const lineOf = (code: string, step: DebugStep): number | null => + step.sourceLocation ? code.slice(0, step.sourceLocation.start).split('\n').length : null; + +describe('Debugger stepping into functions', () => { + const praxisFn = `int double_it(int n) + int result <- n * 2 + return result +end double_it + +int x <- 5 +int y <- double_it(x) +print y +`; + + it('steps through the function body line by line for a call in an assignment', () => { + const steps = collectSteps('praxis', praxisFn); + const lines = steps.map((s) => lineOf(praxisFn, s)); + // call site announced (7), body assignment (2), return (3), back at call site (7) + expect(lines).toEqual([6, 7, 2, 3, 7, 8, null]); + expect(steps[steps.length - 1].output).toEqual(['10']); + }); + + it('shows the function locals while inside and destroys them after return', () => { + const steps = collectSteps('praxis', praxisFn); + const bodyStep = steps.find((s) => lineOf(praxisFn, s) === 2)!; + expect(bodyStep.variables).toMatchObject({ n: 5, result: 10, x: 5 }); + + const returnStep = steps.find((s) => s.nodeType === 'Return')!; + expect(returnStep.variables).toMatchObject({ n: 5, result: 10 }); + + // Back at the call site: the function frame's locals are gone. + const afterReturn = steps[steps.indexOf(returnStep) + 1]; + expect(afterReturn.variables).toEqual({ x: 5, y: 10 }); + expect(afterReturn.callStack).toHaveLength(1); + }); + + it('tracks the call stack while inside the function', () => { + const steps = collectSteps('praxis', praxisFn); + const bodyStep = steps.find((s) => lineOf(praxisFn, s) === 2)!; + expect(bodyStep.callStack.map((f) => f.name)).toEqual(['global', 'double_it']); + expect(bodyStep.callStack[1].variables).toEqual({ n: 5, result: 10 }); + // Function and class declarations never appear as variables. + for (const step of steps) { + expect(step.variables).not.toHaveProperty('double_it'); + } + }); + + it('steps into a call used inside a print statement', () => { + const code = `int double_it(int n) + return n * 2 +end double_it + +print double_it(4) +`; + const steps = collectSteps('praxis', code); + expect(steps.some((s) => s.nodeType === 'Return' && lineOf(code, s) === 2)).toBe(true); + expect(steps[steps.length - 1].output).toEqual(['8']); + }); + + it('steps into a call used inside an if condition', () => { + const code = `boolean big(int n) + return n > 10 +end big + +if (big(20)) + print "big" +end if +`; + const steps = collectSteps('praxis', code); + expect(steps.some((s) => s.nodeType === 'Return' && lineOf(code, s) === 2)).toBe(true); + expect(steps[steps.length - 1].output).toEqual(['big']); + }); + + it('steps through nested calls innermost-first', () => { + const code = `int inc(int n) + return n + 1 +end inc + +int twice(int n) + return n * 2 +end twice + +print twice(inc(3)) +`; + const steps = collectSteps('praxis', code); + const frameNames = steps + .filter((s) => s.nodeType === 'Return') + .map((s) => s.callStack[s.callStack.length - 1].name); + expect(frameNames).toEqual(['inc', 'twice']); + expect(steps[steps.length - 1].output).toEqual(['8']); + }); + + it('stacks frames for recursive calls', () => { + const code = `int fact(int n) + if (n <= 1) + return 1 + end if + return n * fact(n - 1) +end fact + +print fact(3) +`; + const steps = collectSteps('praxis', code); + const deepest = Math.max(...steps.map((s) => s.callStack.length)); + expect(deepest).toBe(4); // global + fact(3) + fact(2) + fact(1) + expect(steps[steps.length - 1].output).toEqual(['6']); + }); + + it('produces the same output as a normal run when a call mixes with arithmetic', () => { + const code = `int askQ(int n) + print "asked" + return n +end askQ + +int score <- 1 +score <- score + askQ(10) +print score +`; + const steps = collectSteps('praxis', code); + // The call body ran exactly once (one "asked"), and arithmetic used its result. + expect(steps[steps.length - 1].output).toEqual(['asked', '11']); + }); +}); + +describe('Debugger stepping into methods (Java / Python)', () => { + it('steps into a sibling static method called from main()', () => { + const code = `public class Main { + public static int doubleIt(int n) { + int result = n * 2; + return result; + } + + public static void main(String[] args) { + int x = 5; + int y = doubleIt(x); + System.out.println(y); + } +} +`; + const steps = collectSteps('java', code); + expect(steps[steps.length - 1].output).toEqual(['10']); + + const bodyStep = steps.find((s) => lineOf(code, s) === 3)!; + expect(bodyStep).toBeDefined(); + expect(bodyStep.callStack.map((f) => f.name)).toEqual(['global', 'main', 'doubleIt']); + expect(bodyStep.callStack[2].variables).toEqual({ n: 5, result: 10 }); + }); + + it('steps into an instance method called on an object', () => { + const code = `class Counter: + def __init__(self): + self.count = 0 + + def bump(self, amount): + self.count = self.count + amount + return self.count + +c = Counter() +print(c.bump(2)) +`; + const steps = collectSteps('python', code); + expect(steps[steps.length - 1].output).toEqual(['2']); + const inMethod = steps.find((s) => s.callStack[s.callStack.length - 1]?.name === 'bump')!; + expect(inMethod).toBeDefined(); + expect(inMethod.callStack[1].variables).toMatchObject({ amount: 2 }); + }); +}); + +describe('Debugger control flow', () => { + it('handles break inside a while loop without aborting the program', () => { + const code = `int i <- 0 +while (i < 10) + if (i == 2) + break + end if + print i + i <- i + 1 +end while +print "done" +`; + const steps = collectSteps('praxis', code); + expect(steps[steps.length - 1].output).toEqual(['0', '1', 'done']); + expect(steps.some((s) => s.nodeType === 'Break')).toBe(true); + }); + + it('yields a step for the return line itself', () => { + const code = `void hello() + print "hi" + return +end hello + +hello() +`; + const steps = collectSteps('praxis', code); + expect(steps.some((s) => s.nodeType === 'Return')).toBe(true); + expect(steps[steps.length - 1].output).toEqual(['hi']); + }); + + it('reports arity errors the same way a normal run does', () => { + const code = `int inc(int n) + return n + 1 +end inc + +print inc(1, 2) +`; + const steps = collectSteps('praxis', code); + const last = steps[steps.length - 1]; + expect(last.isComplete).toBe(true); + expect(last.output.join('\n')).toContain('Expected 1 arguments but got 2'); + }); +}); + +describe('Debugger input handling inside functions', () => { + it('pauses for input() inside a function body and resumes after provideInput', () => { + const code = `String ask() + String name <- input("name?") + return name +end ask + +String who <- ask() +print who +`; + const dbg = new Debugger(); + dbg.init(parse('praxis', code), 'praxis', code); + + let step: DebugStep | null = null; + for (let i = 0; i < 20; i++) { + step = dbg.step(); + expect(step).not.toBeNull(); + if (step!.waitingForInput) break; + } + expect(step!.waitingForInput).toBe(true); + expect(step!.inputPrompt).toBe('name?'); + // We paused inside the function: its frame is on the stack. + expect(step!.callStack.map((f) => f.name)).toEqual(['global', 'ask']); + + dbg.provideInput('Ada'); + const after: DebugStep[] = []; + for (let i = 0; i < 20; i++) { + const s = dbg.step(); + if (!s) break; + after.push(s); + if (s.isComplete) break; + } + const last = after[after.length - 1]; + expect(last.isComplete).toBe(true); + expect(last.output).toEqual(['> Ada', 'Ada']); + }); +}); + +describe('Debugger translation-panel highlighting (emitter source maps)', () => { + const TARGETS = ['python', 'javascript', 'java', 'csp', 'praxis'] as const; + + it('maps a nested else-if to the elif / else-if line in every target', () => { + // The newScore example: the nested If inside `else` is emitted as a + // collapsed elif / else-if chain by the emitters. + const code = `int newScore ( int diceOne, int diceTwo, int oldScore ) + if ( diceOne == diceTwo ) + return 0 + else + if ( ( diceOne == 6 ) or ( diceTwo == 6 ) ) + return oldScore + else + return oldScore + diceOne + diceTwo + end if + end if +end newScore + +print newScore(1, 2, 3) +`; + const program = parse('praxis', code); + + // Step until the debugger sits on the nested If (the second If yielded). + const dbg = new Debugger(); + dbg.init(program, 'praxis', code); + let nestedIf: DebugStep | undefined; + for (let i = 0; i < 50; i++) { + const s = dbg.step(); + if (!s || s.isComplete) break; + if (s.nodeType === 'If' && lineOf(code, s) === 5) nestedIf = s; + } + expect(nestedIf).toBeDefined(); + + const expectedLineText: Record = { + python: 'elif', + javascript: 'else if', + java: 'else if', + csp: 'ELSE IF', + praxis: 'else if', + }; + for (const target of TARGETS) { + const { code: out, sourceMap } = new Translator().translateWithMap(program, target); + const line = sourceMap.get(nestedIf!.nodeId); + expect(line, `${target} should map the nested If`).toBeDefined(); + expect(out.split('\n')[line!], `${target} line ${line}`).toContain(expectedLineText[target]); + } + }); + + it('maps every debugger step to a line in every target for class-free code', () => { + const code = `int helper(int n) + if (n > 3) + return n + else if (n > 1) + return n * 10 + else + return 0 + end if +end helper + +int i <- 0 +while (i < 3) + if (i == 1) + i <- i + 2 + continue + end if + print helper(i) + i <- i + 1 +end while + +for (int j <- 0; j < 4; j <- j + 1) + if (j == 2) + break + end if + print j +end for + +do + i <- i - 1 +while (i > 2) + +repeat + i <- i + 1 +until (i > 4) +`; + // Node ids are random per parse, so the debugger and the translators must + // share one parsed program — exactly as the editor pages do. + const program = parse('praxis', code); + const maps = TARGETS.map( + (t) => [t, new Translator().translateWithMap(program, t).sourceMap] as const + ); + + const dbg = new Debugger(); + dbg.init(program, 'praxis', code); + for (let i = 0; i < 500; i++) { + const step = dbg.step(); + if (!step || step.isComplete) break; + if (!step.nodeId) continue; + for (const [target, map] of maps) { + expect( + map.get(step.nodeId), + `${target} is missing a line for ${step.nodeType} (praxis line ${lineOf(code, step)})` + ).toBeDefined(); + } + } + }); +}); From 370257c8ac68a5f5cc6999400c8e976a0f60304d Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 08:31:41 -0400 Subject: [PATCH 02/15] feat(editor): tweak example programs and UI --- src/components/editor/EditorHeader.tsx | 2 +- src/pages/EditorPage.tsx | 1 - src/utils/sampleCodes.ts | 64 +++++++++++++------------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index da241fe..59e966c 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -109,7 +109,7 @@ export function EditorHeader({ id="examples-listbox" role="listbox" aria-label="Example programs" - className="absolute top-full right-0 mt-2 w-80 max-h-[360px] overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-[220]" + className="absolute top-full right-0 mt-2 w-80 max-h-[80vh] overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-[220]" >
Load Example Program diff --git a/src/pages/EditorPage.tsx b/src/pages/EditorPage.tsx index 206ef9c..a5ca010 100644 --- a/src/pages/EditorPage.tsx +++ b/src/pages/EditorPage.tsx @@ -707,7 +707,6 @@ export default function EditorPage() { setSourceLang(example.lang); setCode(example.code); - setPanels((prev) => prev.filter((panel) => panel.lang !== example.lang)); setOutput([]); setError(null); setHasRun(false); diff --git a/src/utils/sampleCodes.ts b/src/utils/sampleCodes.ts index 081e80c..2cc6f91 100644 --- a/src/utils/sampleCodes.ts +++ b/src/utils/sampleCodes.ts @@ -27,7 +27,7 @@ export const EXAMPLE_CATEGORIES: Record = { export const EXAMPLE_PROGRAMS: ExampleProgram[] = [ { id: 'praxis-dice-score', - title: 'Dice Score Function', + title: 'Praxis Dice Score', description: 'Nested conditionals and return values', category: 'functions', lang: 'praxis', @@ -52,9 +52,37 @@ print newScore(1, 2, 3) description: 'Simple counting loop with output', category: 'loops', lang: 'praxis', - code: `for i <- 0; i < 5; i <- i + 1 - print(i) + code: `for ( i <- 0; i < 5; i <- i + 1 ) + print i end for +`, + }, + { + id: 'csp-repeat-until', + title: 'CSP Repeat Until', + description: 'Repeat-until loop with arithmetic update', + category: 'loops', + lang: 'csp', + code: `x <- 0 +REPEAT UNTIL (x >= 5) +{ + x <- x + 1 +} +DISPLAY(x) +`, + }, + { + id: 'csp-procedure-greet', + title: 'CSP Procedure', + description: 'Procedure declaration and call', + category: 'functions', + lang: 'csp', + code: `PROCEDURE greet(name) +{ + DISPLAY("Hello " + name) +} + +greet("Praxly") `, }, { @@ -110,7 +138,7 @@ print("Rolled a 6 after", attempts, "tries") }, { id: 'java-if-branch', - title: 'Java Conditional Branch', + title: 'Java Conditional', description: 'If / else with numeric comparison', category: 'conditionals', lang: 'java', @@ -120,34 +148,6 @@ if (x < 10) { } else { System.out.println("big"); } -`, - }, - { - id: 'csp-repeat-until', - title: 'CSP Repeat Until', - description: 'Repeat-until loop with arithmetic update', - category: 'loops', - lang: 'csp', - code: `x <- 0 -REPEAT UNTIL (x >= 5) -{ - x <- x + 1 -} -DISPLAY(x) -`, - }, - { - id: 'csp-procedure-greet', - title: 'CSP Procedure', - description: 'Procedure declaration and call', - category: 'functions', - lang: 'csp', - code: `PROCEDURE greet(name) -{ - DISPLAY("Hello " + name) -} - -greet("Praxly") `, }, { From 9de279572dba1be566b82ffff72f50f278de4002 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 08:56:05 -0400 Subject: [PATCH 03/15] =?UTF-8?q?feat(praxis,csp):=20canonicalize=20assign?= =?UTF-8?q?ment=20arrow=20output=20to=20=E2=86=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emitters now generate ← instead of ASCII <- for Praxis and CSP, matching the languages' authoritative pseudocode notation (specs already documented this but emitted <-). Parsers still accept <- and ⟵ as input variants; CSP additionally now accepts ⟵, mirroring what Praxis already allowed. Updated demo programs, sample catalog entries, and the CSP CodeMirror grammar (regenerated) to match. Co-Authored-By: Claude Sonnet 5 --- examples/demo.csp | 36 +++++++-------- examples/demo.praxis | 78 ++++++++++++++++----------------- specs/csp.md | 1 + src/language/csp/csp.grammar | 2 +- src/language/csp/csp.grammar.js | 2 +- src/language/csp/emitter.ts | 14 +++--- src/language/csp/lexer.ts | 2 +- src/language/praxis/emitter.ts | 46 ++++++++++--------- src/utils/sampleCodes.ts | 6 +-- tests/csp.test.ts | 13 ++++-- tests/placeholder.test.ts | 2 +- tests/praxis.test.ts | 8 ++-- 12 files changed, 108 insertions(+), 102 deletions(-) diff --git a/examples/demo.csp b/examples/demo.csp index 331a5d3..18fe102 100644 --- a/examples/demo.csp +++ b/examples/demo.csp @@ -11,10 +11,10 @@ // Switch / Try / Break / Continue ..... no such keywords. // ConditionalExpression (ternary) ..... not parsed. // UpdateExpression (++/--) ............ not in CSP. -// CompoundAssignment (+=) ............. write `x <- x + 1`. +// CompoundAssignment (+=) ............. write `x ← x + 1`. // // CSP runtime facts honored below: -// * Assignment is `<-` (or the unicode arrow); `=` means EQUALITY. +// * Assignment is `←` (ASCII `<-` also accepted); `=` means EQUALITY. // * Lists are 1-based (AP CSP). // * `REPEAT UNTIL(c)` is a PRE-condition loop (runs while NOT c). // * There is no counting FOR: use a counter + REPEAT UNTIL, or REPEAT n TIMES. @@ -27,25 +27,25 @@ // ========================== EXPRESSIONS ==================================== // ---- Literal (number, string, boolean) + Assignment + DISPLAY ------------- -name <- "csp" -greeting <- "hello " + name // BinaryExpression '+' (string concatenation) +name ← "csp" +greeting ← "hello " + name // BinaryExpression '+' (string concatenation) DISPLAY(greeting) // hello csp -flag <- true +flag ← true DISPLAY(flag) // true // ---- BinaryExpression: arithmetic + MOD (division is floating point) ------- -total <- 3 + 4 * 2 +total ← 3 + 4 * 2 DISPLAY(total) // 11 DISPLAY(17 MOD 5) // 2 DISPLAY(10 / 4) // 2.5 // ---- No unary minus in CSP -> subtract from zero --------------------------- -neg <- 0 - 7 +neg ← 0 - 7 DISPLAY(neg) // -7 // ---- Comparison + logical (AND / OR / NOT) incl. unicode relational forms -- -x <- 5 +x ← 5 DISPLAY(x >= 5 AND x < 10) // true DISPLAY(x = 5 OR x = 6) // true DISPLAY(NOT (x = 3)) // true (UnaryExpression NOT) @@ -55,9 +55,9 @@ DISPLAY(x ≤ 5) // true (unicode less-or-equal) DISPLAY(x ≥ 5) // true (unicode greater-or-equal) // ---- ArrayLiteral + IndexExpression (1-based) + index Assignment ----------- -nums <- [10, 20, 30] +nums ← [10, 20, 30] DISPLAY(nums[1]) // 10 (first element is index 1) -nums[2] <- 99 +nums[2] ← 99 DISPLAY(nums[2]) // 99 // ---- List builtins: APPEND / INSERT / REMOVE / LENGTH (1-based positions) -- @@ -68,7 +68,7 @@ DISPLAY(LENGTH(nums)) // 4 DISPLAY(nums[1]) // 5 // ---- String functions: CONCAT / SUBSTRING / CHARAT / len (1-based) --------- -word <- "algorithm" +word ← "algorithm" DISPLAY(CONCAT("al", "go")) // algo DISPLAY(SUBSTRING(word, 1, 4)) // algo (characters 1..4, inclusive) DISPLAY(CHARAT(word, 1)) // a (the first character) @@ -78,7 +78,7 @@ DISPLAY(len(word)) // 9 // ========================== STATEMENTS ===================================== // ---- IF / ELSE IF / ELSE --------------------------------------------------- -score <- 82 +score ← 82 IF (score >= 90) { DISPLAY("A") @@ -93,11 +93,11 @@ ELSE } // ---- REPEAT UNTIL(c) -> pre-condition While(NOT c) ------------------------- -i <- 0 +i ← 0 REPEAT UNTIL (i >= 3) { DISPLAY(i) // 0 1 2 - i <- i + 1 + i ← i + 1 } // ---- REPEAT n TIMES -> count-based For ------------------------------------- @@ -107,11 +107,11 @@ REPEAT 3 TIMES } // ---- Counting loop: counter + REPEAT UNTIL (CSP has no FOR FROM/TO) -------- -j <- 0 +j ← 0 REPEAT UNTIL (j >= 6) { DISPLAY(j) // 0 2 4 - j <- j + 2 + j ← j + 2 } // ---- FOR EACH item IN list ------------------------------------------------- @@ -136,14 +136,14 @@ PROCEDURE isEven(n) PROCEDURE printEvens(limit) { - i <- 0 + i ← 0 REPEAT UNTIL (i >= limit) // 0 .. limit-1 { IF (isEven(i)) { DISPLAY(i) } - i <- i + 1 + i ← i + 1 } } diff --git a/examples/demo.praxis b/examples/demo.praxis index 1ea6a7a..31c7727 100644 --- a/examples/demo.praxis +++ b/examples/demo.praxis @@ -7,7 +7,7 @@ // AST nodes NOT reachable from Praxis source (covered by the other demos): // Switch / SwitchCase .. no switch/case keywords. // ConditionalExpression no ?: ternary operator. -// CompoundAssignment ... no +=/-=; write `x <- x + 1`. +// CompoundAssignment ... no +=/-=; write `x ← x + 1`. // ForEach .............. no for-each; Praxis has only the C-style for loop. // // (Functions and classes are hoisted, so the driver calls them before their @@ -19,27 +19,27 @@ banner("literals and operators") // ---- Literal (int, float, string, boolean, null) + Assignment -------------- -int whole <- 42 -double frac <- 3.14 -string label <- "praxis" -boolean flag <- true -string empty <- null +int whole ← 42 +double frac ← 3.14 +string label ← "praxis" +boolean flag ← true +string empty ← null print(whole, frac, label, flag, empty) // Print with multiple expressions -// `=` is also a valid assignment operator in Praxis (same as `<-`) -int m <- 0 +// `=` is also a valid assignment operator in Praxis (same as `←`) +int m ← 0 m = 10 print(m) // ---- char literal + string escape sequences -------------------------------- -char grade <- 'A' // single-quoted, exactly one character -string tabbed <- "col1\tcol2" // \t \n \r \" \\ are recognized escapes +char grade ← 'A' // single-quoted, exactly one character +string tabbed ← "col1\tcol2" // \t \n \r \" \\ are recognized escapes print(grade) // A print(tabbed) // col1col2 // ---- BinaryExpression: arithmetic (+ - * / % ^) ---------------------------- -int a <- 17 -int b <- 5 +int a ← 17 +int b ← 5 print(a + b) // 22 print(a - b) // 12 print(a * b) // 85 @@ -61,7 +61,7 @@ print(a ≠ b) // true (unicode ≠) print(a < b or a > b) // true // ---- UpdateExpression (++ and --, prefix and postfix) ---------------------- -int i <- 5 +int i ← 5 i++ print(i) // 6 print(++i) // 7 @@ -69,8 +69,8 @@ i-- print(i) // 6 // ---- ArrayLiteral / IndexExpression / MemberExpression / CallExpression ---- -int[] xs <- {5, 3, 8, 1} // brace form (idiomatic); [5, 3, 8, 1] also works -xs[0] <- 50 // index Assignment +int[] xs ← {5, 3, 8, 1} // brace form (idiomatic); [5, 3, 8, 1] also works +xs[0] ← 50 // index Assignment print(xs[0]) // 50 print(xs.length) // 4 (MemberExpression) xs.append(99) // method CallExpression as ExpressionStatement @@ -78,8 +78,8 @@ print(xs.length) // 5 print(xs) // {50, 3, 8, 1, 99} // ---- ArrayCreation: new int[n] (n default-initialized elements) ------------ -int[] buffer <- new int[3] // {0, 0, 0} -buffer[1] <- 7 +int[] buffer ← new int[3] // {0, 0, 0} +buffer[1] ← 7 print(buffer) // {0, 7, 0} print(buffer.length) // 3 @@ -109,7 +109,7 @@ print("tail") // -> "joined: tail" on one line banner("control flow") // ---- If / else if / else (a single `end if` closes the whole chain) -------- -int score <- 75 +int score ← 75 if (score >= 90) print("A") else if (score >= 70) @@ -119,45 +119,45 @@ else end if // ---- While (pre-condition loop) -------------------------------------------- -int w <- 0 +int w ← 0 while (w < 3) print(w) // 0 1 2 - w <- w + 1 + w ← w + 1 end while // ---- For, C-style (init / condition / update) ------------------------------ -int total <- 0 -for j <- 0; j < xs.length; j <- j + 1 - total <- total + xs[j] +int total ← 0 +for j ← 0; j < xs.length; j ← j + 1 + total ← total + xs[j] end for print(total) // 161 // ---- For, iterate an array by index (Praxis has only the C-style for) ------ -for p <- 0; p < xs.length; p <- p + 1 +for p ← 0; p < xs.length; p ← p + 1 print(xs[p]) end for // ---- For, counting loop ---------------------------------------------------- -for k <- 1; k < 4; k <- k + 1 +for k ← 1; k < 4; k ← k + 1 print(k) // 1 2 3 end for // ---- DoWhile (post-condition; repeats WHILE the condition is true) --------- -int d <- 0 +int d ← 0 do print(d) // 0 1 2 - d <- d + 1 + d ← d + 1 while (d < 3) // ---- RepeatUntil (post-condition; repeats UNTIL the condition is true) ----- -int r <- 0 +int r ← 0 repeat print(r) // 0 1 2 - r <- r + 1 + r ← r + 1 until (r >= 3) // ---- Break / Continue ------------------------------------------------------ -for t <- 0; t < 5; t <- t + 1 +for t ← 0; t < 5; t ← t + 1 if (t == 1) continue // skip printing 1 end if @@ -225,12 +225,12 @@ class Counter private int step public Counter(int start) // constructor named after the class - count <- start // bare field assignment (no `this`) - step <- 1 + count ← start // bare field assignment (no `this`) + step ← 1 end Counter public void increment() - count <- count + step + count ← count + step end increment public int getCount() @@ -244,8 +244,8 @@ class Animal private int legs public Animal(string n) // param `n` differs from field `name` - name <- n - legs <- 4 + name ← n + legs ← 4 end Animal public string describe() @@ -265,7 +265,7 @@ end class Dog // ---- Default constructor + field initializer (no constructor defined) ------ class Box - string contents <- "gift" // field initialized at declaration + string contents ← "gift" // field initialized at declaration public string open() return "opened " + contents @@ -273,16 +273,16 @@ class Box end class Box // ---- NewExpression / methods / fields / inheritance ------------------------ -Counter c <- new Counter(5) +Counter c ← new Counter(5) c.increment() c.increment() print(c.getCount()) // 7 -Dog dog <- new Dog("Rex") +Dog dog ← new Dog("Rex") print(dog.speak()) // woof (Dog's own method) print(dog.describe()) // Rex has 4 legs (inherited field + method via super) -Box box <- new Box() // default constructor +Box box ← new Box() // default constructor print(box.open()) // opened gift banner("done") diff --git a/specs/csp.md b/specs/csp.md index c1e3c8a..3514137 100644 --- a/specs/csp.md +++ b/specs/csp.md @@ -4,6 +4,7 @@ The _AP Computer Science Principles_ exam uses the pseudocode notation described Notice that: - Assignment uses `←`, not `=` + - `<-` and `⟵` are also accepted by the interpreter (`=` is not — it is CSP's equality operator) - Indexes start at `1`, not `0` - Output uses `DISPLAY`, not `print` - Comparison uses `=`, not `==` diff --git a/src/language/csp/csp.grammar b/src/language/csp/csp.grammar index f27cb68..25d367f 100644 --- a/src/language/csp/csp.grammar +++ b/src/language/csp/csp.grammar @@ -151,7 +151,7 @@ kw { @specialize[@name={term}] } Boolean { "true" | "false" | "TRUE" | "FALSE" } Null { "null" | "NULL" | "None" } - AssignOp { "<-" } + AssignOp { "<-" | "←" | "⟵" } Eq { "=" | "==" } Neq { "!=" | "<>" } Lt { "<" } diff --git a/src/language/csp/csp.grammar.js b/src/language/csp/csp.grammar.js index f6562aa..f251043 100644 --- a/src/language/csp/csp.grammar.js +++ b/src/language/csp/csp.grammar.js @@ -10,7 +10,7 @@ export const parser = LRParser.deserialize({ maxTerm: 82, skippedNodes: [0,1], repeatNodeCount: 4, - tokenData: "5|~RvXY#iYZ#i]^#ipq#iqr#zrs$Vwx%sxy'[yz'az{'f{|'k|}'p}!O'u!O!P'z!P!Q(P!Q![(p!^!_)Z!_!`)s!`!a*Q!c!h*_!h!i*p!i!p*_!p!q-Q!q!v*_!v!w0S!w!}*_!}#O1S#P#Q1X#R#S*_#T#Y*_#Y#Z1^#Z#b*_#b#c3Z#c#h*_#h#i4r#i#o*_#o#p5r#q#r5w~#nS!p~XY#iYZ#i]^#ipq#i~#}P!_!`$Q~$VOz~~$YVOr$Vrs$os#O$V#O#P$t#P;'S$V;'S;=`%m<%lO$V~$tOe~~$wRO;'S$V;'S;=`%Q;=`O$V~%TWOr$Vrs$os#O$V#O#P$t#P;'S$V;'S;=`%m;=`<%l$V<%lO$V~%pP;=`<%l$V~%vVOw%swx$ox#O%s#O#P&]#P;'S%s;'S;=`'U<%lO%s~&`RO;'S%s;'S;=`&i;=`O%s~&lWOw%swx$ox#O%s#O#P&]#P;'S%s;'S;=`'U;=`<%l%s<%lO%s~'XP;=`<%l%s~'aO]~~'fO_~~'kOu~~'pOx~~'uO^~~'zOs~~(PO!S~~(UPv~!P!Q(X~(^SP~OY(XZ;'S(X;'S;=`(j<%lO(X~(mP;=`<%l(X~(uQd~!O!P({!Q![(p~)OP!Q![)R~)WPd~!Q![)R~)`R{~}!O)i!_!`)n!`!a$Q~)nOc~~)sO}~~)xPy~!_!`){~*QOy~~*VP|~!_!`*Y~*_O!O~~*dSS~!Q![*_!c!}*_#R#S*_#T#o*_~*uTS~!Q![*_!c!d+U!d!}*_#R#S*_#T#o*_~+ZUS~!Q![*_!c!n*_!n!o+m!o!}*_#R#S*_#T#o*_~+rUS~!Q![*_!c!u*_!u!v,U!v!}*_#R#S*_#T#o*_~,ZUS~!Q![*_!c!g*_!g!h,m!h!}*_#R#S*_#T#o*_~,tSf~S~!Q![*_!c!}*_#R#S*_#T#o*_~-VWS~!Q![*_!c!w*_!w!x-o!x!}*_#R#S*_#T#c*_#c#d/S#d#o*_~-tUS~!Q![*_!c!n*_!n!o.W!o!}*_#R#S*_#T#o*_~.]US~!Q![*_!c!n*_!n!o.o!o!}*_#R#S*_#T#o*_~.vSg~S~!Q![*_!c!}*_#R#S*_#T#o*_~/XUS~!Q![*_!c!}*_#R#S*_#T#b*_#b#c/k#c#o*_~/pUS~!Q![*_!c!}*_#R#S*_#T#X*_#X#Y.o#Y#o*_~0XUS~!Q![*_!c!t*_!t!u0k!u!}*_#R#S*_#T#o*_~0pUS~!Q![*_!c!w*_!w!x,U!x!}*_#R#S*_#T#o*_~1XOk~~1^Ol~~1cTS~!Q![*_!c!}*_#R#S*_#T#U1r#U#o*_~1wUS~!Q![*_!c!}*_#R#S*_#T#`*_#`#a2Z#a#o*_~2`US~!Q![*_!c!}*_#R#S*_#T#g*_#g#h2r#h#o*_~2wUS~!Q![*_!c!}*_#R#S*_#T#X*_#X#Y,m#Y#o*_~3`US~!Q![*_!c!}*_#R#S*_#T#i*_#i#j3r#j#o*_~3wUS~!Q![*_!c!}*_#R#S*_#T#`*_#`#a4Z#a#o*_~4`US~!Q![*_!c!}*_#R#S*_#T#`*_#`#a.o#a#o*_~4wUS~!Q![*_!c!}*_#R#S*_#T#f*_#f#g5Z#g#o*_~5`US~!Q![*_!c!}*_#R#S*_#T#i*_#i#j2r#j#o*_~5wOV~~5|Oa~", + tokenData: "6S~RxXY#oYZ#o]^#opq#oqr$Qrs$]wx%yxy'byz'gz{'l{|'q|}'v}!O'{!O!P(Q!P!Q(V!Q![(v!^!_)a!_!`)y!`!a*W!c!h*e!h!i*v!i!p*e!p!q-W!q!v*e!v!w0Y!w!}*e!}#O1Y#P#Q1_#R#S*e#T#Y*e#Y#Z1d#Z#b*e#b#c3a#c#h*e#h#i4x#i#o*e#o#p5x#q#r5}%#t%#u)o%Ga%Gb)o~#tS!p~XY#oYZ#o]^#opq#o~$TP!_!`$W~$]Oz~~$`VOr$]rs$us#O$]#O#P$z#P;'S$];'S;=`%s<%lO$]~$zOe~~$}RO;'S$];'S;=`%W;=`O$]~%ZWOr$]rs$us#O$]#O#P$z#P;'S$];'S;=`%s;=`<%l$]<%lO$]~%vP;=`<%l$]~%|VOw%ywx$ux#O%y#O#P&c#P;'S%y;'S;=`'[<%lO%y~&fRO;'S%y;'S;=`&o;=`O%y~&rWOw%ywx$ux#O%y#O#P&c#P;'S%y;'S;=`'[;=`<%l%y<%lO%y~'_P;=`<%l%y~'gO]~~'lO_~~'qOu~~'vOx~~'{O^~~(QOs~~(VO!S~~([Pv~!P!Q(_~(dSP~OY(_Z;'S(_;'S;=`(p<%lO(_~(sP;=`<%l(_~({Qd~!O!P)R!Q![(v~)UP!Q![)X~)^Pd~!Q![)X~)fR{~}!O)o!_!`)t!`!a$W~)tOc~~)yO}~~*OPy~!_!`*R~*WOy~~*]P|~!_!`*`~*eO!O~~*jSS~!Q![*e!c!}*e#R#S*e#T#o*e~*{TS~!Q![*e!c!d+[!d!}*e#R#S*e#T#o*e~+aUS~!Q![*e!c!n*e!n!o+s!o!}*e#R#S*e#T#o*e~+xUS~!Q![*e!c!u*e!u!v,[!v!}*e#R#S*e#T#o*e~,aUS~!Q![*e!c!g*e!g!h,s!h!}*e#R#S*e#T#o*e~,zSf~S~!Q![*e!c!}*e#R#S*e#T#o*e~-]WS~!Q![*e!c!w*e!w!x-u!x!}*e#R#S*e#T#c*e#c#d/Y#d#o*e~-zUS~!Q![*e!c!n*e!n!o.^!o!}*e#R#S*e#T#o*e~.cUS~!Q![*e!c!n*e!n!o.u!o!}*e#R#S*e#T#o*e~.|Sg~S~!Q![*e!c!}*e#R#S*e#T#o*e~/_US~!Q![*e!c!}*e#R#S*e#T#b*e#b#c/q#c#o*e~/vUS~!Q![*e!c!}*e#R#S*e#T#X*e#X#Y.u#Y#o*e~0_US~!Q![*e!c!t*e!t!u0q!u!}*e#R#S*e#T#o*e~0vUS~!Q![*e!c!w*e!w!x,[!x!}*e#R#S*e#T#o*e~1_Ok~~1dOl~~1iTS~!Q![*e!c!}*e#R#S*e#T#U1x#U#o*e~1}US~!Q![*e!c!}*e#R#S*e#T#`*e#`#a2a#a#o*e~2fUS~!Q![*e!c!}*e#R#S*e#T#g*e#g#h2x#h#o*e~2}US~!Q![*e!c!}*e#R#S*e#T#X*e#X#Y,s#Y#o*e~3fUS~!Q![*e!c!}*e#R#S*e#T#i*e#i#j3x#j#o*e~3}US~!Q![*e!c!}*e#R#S*e#T#`*e#`#a4a#a#o*e~4fUS~!Q![*e!c!}*e#R#S*e#T#`*e#`#a.u#a#o*e~4}US~!Q![*e!c!}*e#R#S*e#T#f*e#f#g5a#g#o*e~5fUS~!Q![*e!c!}*e#R#S*e#T#i*e#i#j2x#j#o*e~5}OV~~6SOa~", tokenizers: [0], topRules: {"Program":[0,2]}, specialized: [{term: 4, get: (value) => spec_Identifier[value] || -1}], diff --git a/src/language/csp/emitter.ts b/src/language/csp/emitter.ts index cdaa43c..b26af4e 100644 --- a/src/language/csp/emitter.ts +++ b/src/language/csp/emitter.ts @@ -6,7 +6,7 @@ * * Key dialect rules enforced here: * - IF / ELSE IF / ELSE chains (single nested-IF else branch -> ELSE IF) - * - Assignment arrow is <- (spec uses ←, both accepted on input) + * - Assignment arrow is ← (ASCII <- and long-arrow ⟵ also accepted on input) * - Equality operator is = (not ==) * - Not-equal is ≠, but <> is also emitted for ASCII compatibility * - RETURN uses parens: RETURN(value) @@ -103,7 +103,7 @@ export class CSPEmitter extends ASTVisitor { stmt.value.elements.forEach((val: any, i: number) => { const t = targets[i]; if (t?.type === 'Identifier') { - this.emit(`${t.name} <- ${this.generateExpression(val, 0)}`, stmt.id); + this.emit(`${t.name} ← ${this.generateExpression(val, 0)}`, stmt.id); } }); } @@ -116,7 +116,7 @@ export class CSPEmitter extends ASTVisitor { } const targetStr = this.generateExpression(stmt.target, 0); - this.emit(`${targetStr} <- ${this.generateExpression(stmt.value, 0)}`, stmt.id); + this.emit(`${targetStr} ← ${this.generateExpression(stmt.value, 0)}`, stmt.id); } visitIf(stmt: any): void { @@ -293,7 +293,7 @@ export class CSPEmitter extends ASTVisitor { } } - // Emits `var <- start; REPEAT UNTIL (var end) { body; var <- var + step }`, + // Emits `var ← start; REPEAT UNTIL (var end) { body; var ← var + step }`, // the spec-legal way to express a counting loop in CSP. private emitCounterLoop( variable: string, @@ -305,12 +305,12 @@ export class CSPEmitter extends ASTVisitor { id: string ): void { this.context.symbolTable.enterScope(); - this.emit(`${variable} <- ${start}`, id); + this.emit(`${variable} ← ${start}`, id); this.emit(`REPEAT UNTIL (${variable} ${cmp} ${end})`); this.emit('{'); this.indent(); this.visitBlock(body); - this.emit(`${variable} <- ${variable} + ${step}`); + this.emit(`${variable} ← ${variable} + ${step}`); this.dedent(); this.emit('}'); this.context.symbolTable.exitScope(); @@ -453,7 +453,7 @@ export class CSPEmitter extends ASTVisitor { case 'UpdateExpression': { const argStr = this.generateExpression((expr as any).argument, Precedence.Unary); const op = (expr as any).operator === '++' ? '+' : '-'; - output = `${argStr} <- ${argStr} ${op} 1`; + output = `${argStr} ← ${argStr} ${op} 1`; break; } diff --git a/src/language/csp/lexer.ts b/src/language/csp/lexer.ts index bb0ebbf..c6925fe 100644 --- a/src/language/csp/lexer.ts +++ b/src/language/csp/lexer.ts @@ -118,7 +118,7 @@ export class CSPLexer { } // Unicode symbols for assignment and relational operators - if (char === '←') { + if (char === '←' || char === '⟵') { tokens.push({ type: 'OPERATOR', value: '<-', start: this.pos++ }); continue; } diff --git a/src/language/praxis/emitter.ts b/src/language/praxis/emitter.ts index 678ba5f..60e4e5f 100644 --- a/src/language/praxis/emitter.ts +++ b/src/language/praxis/emitter.ts @@ -34,7 +34,7 @@ export class PraxisEmitter extends ASTVisitor { private currentClassName = ''; // Parameter names of the constructor/method currently being emitted. A field // access is qualified with `this.` only when the field name is shadowed by one - // of these (e.g. `this.name <- name`); otherwise the bare field name is used. + // of these (e.g. `this.name ← name`); otherwise the bare field name is used. private currentParams = new Set(); // Declared class names — used to render a bare constructor call (e.g. Python's // `Animal("Rex")`) as a Praxis `new Animal("Rex")` and to type the target. @@ -193,7 +193,7 @@ export class PraxisEmitter extends ASTVisitor { if (type === 'auto') type = 'var'; let line = `${field.access} ${type} ${field.name}`; - if (field.initializer) line += ` <- ${this.generateExpression(field.initializer, 0)}`; + if (field.initializer) line += ` ← ${this.generateExpression(field.initializer, 0)}`; this.emit(line); } @@ -297,10 +297,10 @@ export class PraxisEmitter extends ASTVisitor { let type = this.inferType(values[i]); if (type === 'var') type = 'int'; if (this.context.symbolTable.get(varName) === undefined) { - this.emit(`${type} ${varName} <- ${valStr}`, stmt.id); + this.emit(`${type} ${varName} ← ${valStr}`, stmt.id); this.context.symbolTable.set(varName, type); } else { - this.emit(`${varName} <- ${valStr}`, stmt.id); + this.emit(`${varName} ← ${valStr}`, stmt.id); } }); } @@ -318,9 +318,9 @@ export class PraxisEmitter extends ASTVisitor { const name = lvalueName(stmt); const targetStr = this.generateExpression(stmt.target, 0); - // Member/index mutation (e.g. obj.field <- v, arr[i] <- v) — never a declaration. + // Member/index mutation (e.g. obj.field ← v, arr[i] ← v) — never a declaration. if (name === undefined) { - this.emit(`${targetStr} <- ${rVal}`, stmt.id); + this.emit(`${targetStr} ← ${rVal}`, stmt.id); return; } @@ -331,18 +331,18 @@ export class PraxisEmitter extends ASTVisitor { // `auto`) stays untyped in Praxis: integer values keep their native // display (no forced `.0`), and `/` still divides as float because the // interpreter does not truncate untyped operands. - this.emit(`${targetStr} <- ${initVal}`, stmt.id); + this.emit(`${targetStr} ← ${initVal}`, stmt.id); this.context.symbolTable.set(name, 'auto'); } else { - this.emit(`${type} ${targetStr} <- ${initVal}`, stmt.id); + this.emit(`${type} ${targetStr} ← ${initVal}`, stmt.id); this.context.symbolTable.set(name, type); } } else if (this.context.symbolTable.get(name) !== undefined) { - this.emit(`${targetStr} <- ${rVal}`, stmt.id); + this.emit(`${targetStr} ← ${rVal}`, stmt.id); } else { let type = this.inferType(stmt.value); if (type === 'var') type = 'int'; - this.emit(`${type} ${targetStr} <- ${initVal}`, stmt.id); + this.emit(`${type} ${targetStr} ← ${initVal}`, stmt.id); this.context.symbolTable.set(name, type); } } @@ -460,7 +460,7 @@ export class PraxisEmitter extends ASTVisitor { const rVal = this.generateExpression(stmt.init.value, 0); let type = stmt.init.varType || this.inferType(stmt.init.value); if (type === 'var') type = 'int'; - initCode = `${type} ${initName} <- ${rVal}`; + initCode = `${type} ${initName} ← ${rVal}`; this.context.symbolTable.set(initName, type); } else if (stmt.init) { initCode = this.generateExpression(stmt.init.expression, 0); @@ -469,7 +469,7 @@ export class PraxisEmitter extends ASTVisitor { let updateCode = ''; if (stmt.update?.type === 'Assignment') { const ut = this.generateExpression(stmt.update.target, 0); - updateCode = `${ut} <- ${this.generateExpression(stmt.update.value, 0)}`; + updateCode = `${ut} ← ${this.generateExpression(stmt.update.value, 0)}`; } else if (stmt.update) { updateCode = this.generateExpression(stmt.update.expression, 0); } @@ -494,7 +494,7 @@ export class PraxisEmitter extends ASTVisitor { } if (args.length === 3) step = this.generateExpression(args[2], 0); this.emit( - `for (int ${stmt.variable} <- ${start}; ${stmt.variable} < ${end}; ${stmt.variable} <- ${stmt.variable} + ${step})`, + `for (int ${stmt.variable} ← ${start}; ${stmt.variable} < ${end}; ${stmt.variable} ← ${stmt.variable} + ${step})`, stmt.id ); this.indent(); @@ -510,10 +510,10 @@ export class PraxisEmitter extends ASTVisitor { const arr = `_arr${n}`; const idx = `_i${n}`; this.context.symbolTable.enterScope(); - this.emit(`${arr} <- ${this.generateExpression(stmt.iterable, 0)}`, stmt.id); - this.emit(`for (int ${idx} <- 0; ${idx} < ${arr}.length; ${idx} <- ${idx} + 1)`); + this.emit(`${arr} ← ${this.generateExpression(stmt.iterable, 0)}`, stmt.id); + this.emit(`for (int ${idx} ← 0; ${idx} < ${arr}.length; ${idx} ← ${idx} + 1)`); this.indent(); - this.emit(`${stmt.variable} <- ${arr}[${idx}]`); + this.emit(`${stmt.variable} ← ${arr}[${idx}]`); this.context.symbolTable.set(stmt.variable, 'var'); this.visitBlock(stmt.body); this.dedent(); @@ -554,12 +554,12 @@ export class PraxisEmitter extends ASTVisitor { visitExpressionStatement(stmt: any): void { // A bare `i++` / `--i` statement is ambiguous in Praxis (no statement - // terminators, so `i++\n--j` would re-associate); lower it to `i <- i ± 1`. + // terminators, so `i++\n--j` would re-associate); lower it to `i ← i ± 1`. const e = stmt.expression; if (e?.type === 'UpdateExpression') { const target = this.generateExpression(e.argument, 0); const op = e.operator === '++' ? '+' : '-'; - this.emit(`${target} <- ${target} ${op} 1`, stmt.id); + this.emit(`${target} ← ${target} ${op} 1`, stmt.id); return; } this.emit(this.generateExpression(stmt.expression, 0), stmt.id); @@ -748,12 +748,12 @@ export class PraxisEmitter extends ASTVisitor { } case 'CompoundAssignment': { - // Praxis has no `+=`; expand to `target <- target op right`. + // Praxis has no `+=`; expand to `target ← target op right`. const target = (expr as any).left ? this.generateExpression((expr as any).left, 0) : (expr as any).name; const op = (expr as any).operator; - output = `${target} <- ${target} ${op} ${this.generateExpression((expr as any).right, 0)}`; + output = `${target} ← ${target} ${op} ${this.generateExpression((expr as any).right, 0)}`; break; } @@ -762,12 +762,10 @@ export class PraxisEmitter extends ASTVisitor { const tmp = `_tern${this.tempCounter++}`; this.preludeLines.push(`if (${this.generateExpression((expr as any).test, 0)})`); this.preludeLines.push( - ` ${tmp} <- ${this.generateExpression((expr as any).consequent, 0)}` + ` ${tmp} ← ${this.generateExpression((expr as any).consequent, 0)}` ); this.preludeLines.push(`else`); - this.preludeLines.push( - ` ${tmp} <- ${this.generateExpression((expr as any).alternate, 0)}` - ); + this.preludeLines.push(` ${tmp} ← ${this.generateExpression((expr as any).alternate, 0)}`); this.preludeLines.push(`end if`); output = tmp; break; diff --git a/src/utils/sampleCodes.ts b/src/utils/sampleCodes.ts index 2cc6f91..23e5d03 100644 --- a/src/utils/sampleCodes.ts +++ b/src/utils/sampleCodes.ts @@ -52,7 +52,7 @@ print newScore(1, 2, 3) description: 'Simple counting loop with output', category: 'loops', lang: 'praxis', - code: `for ( i <- 0; i < 5; i <- i + 1 ) + code: `for ( i ← 0; i < 5; i ← i + 1 ) print i end for `, @@ -63,10 +63,10 @@ end for description: 'Repeat-until loop with arithmetic update', category: 'loops', lang: 'csp', - code: `x <- 0 + code: `x ← 0 REPEAT UNTIL (x >= 5) { - x <- x + 1 + x ← x + 1 } DISPLAY(x) `, diff --git a/tests/csp.test.ts b/tests/csp.test.ts index a94b5a6..e3712d9 100644 --- a/tests/csp.test.ts +++ b/tests/csp.test.ts @@ -49,6 +49,13 @@ describe('CSP Lexer', () => { expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<-' })); }); + it('accepts ← and ⟵ as assignment operator variants (not =, which is equality)', () => { + for (const arrow of ['←', '⟵']) { + const tokens = new CSPLexer(`x ${arrow} 5`).tokenize(); + expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<-' })); + } + }); + it('should tokenize not-equal operator', () => { const lexer = new CSPLexer('x <> y'); const tokens = lexer.tokenize(); @@ -247,7 +254,7 @@ describe('CSP Emitter', () => { }); emitter.visitProgram(program); const code = emitter.getGeneratedCode(); - expect(code).toContain('<-'); + expect(code).toContain('←'); }); it('should emit display statement', () => { @@ -364,7 +371,7 @@ describe('CSP Translation', () => { const translator = new Translator(); const result = translator.translate(program, 'csp'); expect(result).toContain('x'); - expect(result).toContain('<-'); + expect(result).toContain('←'); expect(result).toContain('5'); }); @@ -505,7 +512,7 @@ DISPLAY("after")`; const result = new Translator().translate(program, 'csp'); expect(result).not.toContain('FROM'); expect(result).toContain('REPEAT UNTIL'); - expect(result).toContain('i <- i + 2'); + expect(result).toContain('i ← i + 2'); }); it('should handle REPEAT n TIMES with variable', () => { diff --git a/tests/placeholder.test.ts b/tests/placeholder.test.ts index 029d484..6d52dcc 100644 --- a/tests/placeholder.test.ts +++ b/tests/placeholder.test.ts @@ -34,6 +34,6 @@ describe('Praxis placeholder', () => { it('lowers to a default 0 in other targets', () => { expect(to('x <- /* hole */\nprint(x)', 'python')).toContain('x = 0'); expect(to('int x <- /* hole */\nprint(x)', 'java')).toContain('int x = 0;'); - expect(to('x <- /* hole */\nprint(x)', 'csp')).toContain('x <- 0'); + expect(to('x <- /* hole */\nprint(x)', 'csp')).toContain('x ← 0'); }); }); diff --git a/tests/praxis.test.ts b/tests/praxis.test.ts index 23e8ad1..9711d70 100644 --- a/tests/praxis.test.ts +++ b/tests/praxis.test.ts @@ -284,7 +284,7 @@ end add`; }); emitter.visitProgram(program); const code = emitter.getGeneratedCode(); - expect(code).toContain('<-'); + expect(code).toContain('←'); }); it('should emit if statement', () => { @@ -341,7 +341,7 @@ describe('Praxis Translation', () => { const result = translator.translate(program, 'praxis'); expect(result).toContain('int'); expect(result).toContain('x'); - expect(result).toContain('<-'); + expect(result).toContain('←'); }); it('should translate print statement', () => { @@ -670,7 +670,7 @@ end class Box`; const praxis = new Translator().translate(program, 'praxis'); expect(praxis).toContain('public Box(int v)'); expect(praxis).toContain('end Box'); - expect(praxis).toContain('value <- v'); + expect(praxis).toContain('value ← v'); expect(praxis).not.toContain('this.'); expect(praxis).not.toContain('procedure new'); }); @@ -707,7 +707,7 @@ print(s.label())`; }`; const program = new JavaParser(new JavaLexer(src).tokenize()).parse(); const praxis = new Translator().translate(program, 'praxis'); - expect(praxis).toContain('this.name <- name'); // shadowed -> qualified + expect(praxis).toContain('this.name ← name'); // shadowed -> qualified expect(praxis).toContain('return name'); // not shadowed -> bare }); }); From d3015569404281ffbb3ff50aa85021af9575e177 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 08:56:27 -0400 Subject: [PATCH 04/15] feat(csp): accept != as a not-equal operator variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSP grammar (highlighting) and spec already documented != as an accepted ASCII alternate for ≠, but the actual lexer/parser never implemented it — ! wasn't even in the lexer's recognized character set, so it threw "Unexpected character". Wires it up to match the existing docs. Co-Authored-By: Claude Sonnet 5 --- src/language/csp/lexer.ts | 4 +++- src/language/csp/parser.ts | 2 +- tests/csp.test.ts | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/language/csp/lexer.ts b/src/language/csp/lexer.ts index c6925fe..d687baf 100644 --- a/src/language/csp/lexer.ts +++ b/src/language/csp/lexer.ts @@ -135,7 +135,9 @@ export class CSPLexer { continue; } - if (['+', '-', '*', '/', '=', '>', '<', '(', ')', '{', '}', '[', ']', ','].includes(char)) { + if ( + ['+', '-', '*', '/', '=', '>', '<', '!', '(', ')', '{', '}', '[', ']', ','].includes(char) + ) { const start = this.pos; // Check for <- if (char === '<' && this.input[this.pos + 1] === '-') { diff --git a/src/language/csp/parser.ts b/src/language/csp/parser.ts index c7f001d..0faf997 100644 --- a/src/language/csp/parser.ts +++ b/src/language/csp/parser.ts @@ -316,7 +316,7 @@ export class CSPParser { private equality(): Expression { let left = this.comparison(); - while (this.match('OPERATOR', '=', '<>')) { + while (this.match('OPERATOR', '=', '<>', '!=')) { let op = this.previous().value; if (op === '=') op = '=='; if (op === '<>') op = '!='; diff --git a/tests/csp.test.ts b/tests/csp.test.ts index e3712d9..c955c2b 100644 --- a/tests/csp.test.ts +++ b/tests/csp.test.ts @@ -62,6 +62,12 @@ describe('CSP Lexer', () => { expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '<>' })); }); + it('should tokenize != as a not-equal operator variant', () => { + const lexer = new CSPLexer('x != y'); + const tokens = lexer.tokenize(); + expect(tokens).toContainEqual(expect.objectContaining({ type: 'OPERATOR', value: '!=' })); + }); + it('should tokenize comparison operators', () => { const lexer = new CSPLexer('a < b a > c a <= d a >= e'); const tokens = lexer.tokenize(); From 2b28cfe2985e85e3ab8c6c45f213e19e3267e731 Mon Sep 17 00:00:00 2001 From: Chris Mayfield Date: Fri, 17 Jul 2026 12:32:23 -0400 Subject: [PATCH 05/15] feat(editor): add Demo toolbar button to load per-language feature demos Loads examples/demo.* for the currently selected source language via Vite ?raw imports, reusing the same files the round-trip/examples/blocks test suites already read, so the content isn't duplicated anywhere. Co-Authored-By: Claude Sonnet 5 --- src/components/editor/EditorHeader.tsx | 14 ++++++++++++++ src/pages/EditorPage.tsx | 21 +++++++++++++++++++++ src/utils/demoPrograms.ts | 25 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/utils/demoPrograms.ts diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index 59e966c..b6da3f5 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -4,6 +4,7 @@ import { Trash2, Home, BookOpen, + FileCode, Bug, FastForward, Square, @@ -27,10 +28,12 @@ interface EditorHeaderProps { isDebugging: boolean; isDebugComplete: boolean; examples: ExampleProgram[]; + demoAvailable: boolean; textSize: TextSize; onClear: () => void; onShare: () => void; onLoadExample: (exampleId: string) => void; + onLoadDemo: () => void; onToggleExamplesMenu: () => void; onToggleSettingsMenu: () => void; onDebugStart: () => void; @@ -50,10 +53,12 @@ export function EditorHeader({ isDebugging, isDebugComplete, examples, + demoAvailable, textSize, onClear, onShare, onLoadExample, + onLoadDemo, onToggleExamplesMenu, onToggleSettingsMenu, onDebugStart, @@ -92,6 +97,15 @@ export function EditorHeader({ className="flex items-center flex-wrap justify-end gap-2 sm:gap-3" aria-label="Editor controls" > + + {/* Examples menu */}