The main interpreter module is emulator/core.py (CoCoBasic class). All emulator source lives under emulator/. The conftest with test fixtures (basic, helpers, temp_programs_dir) is at conftest.py (project root).
process_statement() in core.py tries these in order:
- Multi-line IF — bare
IF cond THEN(no action). Must be first because the AST parser can't parse it. - File I/O intercepts — PRINT#, INPUT#, LINE INPUT# (and console LINE INPUT). Intercepted before AST because the AST parser doesn't understand
#syntax. _try_ast_execute()— anything not in theCommandRegistry. The parser knows registry command keywords viaregistry_commandsset and raisesRegistryCommandErrorfor them. Handles: END, GOTO, LET, PRINT, GOSUB, RETURN, FOR, EXIT FOR, WHILE, DO, IF, INPUT, ON (GOTO/GOSUB and ERROR GOTO), and implicit assignment (X = 5).CommandRegistry— everything else: NEXT, WEND, LOOP, ELSE, ENDIF, LOCAL, DIM, STOP, CONT, DATA, READ, RESTORE, SOUND, PAUSE, etc.
New control flow → AST visitor in ast_evaluator.py. New utility command → registry via execute_*. New BASIC function → functions.py only.
All program execution goes through _execute_statements_loop() — the shared engine used by run_program(), continue_program_execution(), and execute_cont(). Flow-control is handled by _handle_flow_control() which dispatches jump/skip/pause directives. Helper methods: _find_line_position(), _skip_to_keyword(), _skip_to_next(), _skip_if_or_else_block().
evaluate_expression(expr) on CoCoBasic parses and evaluates a BASIC expression string via the AST (ASTParser.parse_expression() + ASTEvaluator.visit()). evaluate_condition(cond) does the same, returning a boolean. Both cache parsed AST nodes in _expr_cache (keyed by expression string) so repeated evaluations in loops skip the tokenizer and parser. FunctionRegistry (in function_registry.py) maps BASIC function names to handlers in functions.py. Function handlers receive the CoCoBasic instance as their first argument.
AST visitors push; registry closing commands pop:
| Stack | Pushed by | Popped by |
|---|---|---|
for_stack |
visit_for_statement |
execute_next (control_flow.py) |
call_stack |
visit_gosub_statement (4-tuple: line, sub_line, if_depth, for_depth) |
visit_return_statement (also restores if_stack/for_stack depth) |
local_stack |
visit_gosub_statement (empty frame) |
visit_return_statement (restores variables); LOCAL/PRIVATE append entries |
while_stack |
visit_while_statement |
execute_wend (control_flow.py) |
do_stack |
visit_do_statement |
execute_loop (control_flow.py) |
if_stack |
visit_if_statement / multi-line IF handler |
execute_else, execute_endif (control_flow.py) |
Three paths handle IF statements:
- Single-line IF (
IF cond THEN action) — AST parser evaluates directly invisit_if_statement(). No stack involved. - Multi-line IF (bare
IF cond THEN) — detected inprocess_statement(), pushes toif_stack.skip_if_blockdirective tells executor to skip via_skip_if_or_else_block(). - AST-converted single-line (e.g.,
IF A=1 THEN B=2: C=3) — AST converter expands toIF cond THEN/ body /ENDIFsublines, which then follow the multi-line path.
Nesting: _skip_if_or_else_block() counts nested IFs by checking stmt.startswith('IF ') (not substring match, to avoid false positives from strings like PRINT "IF THEN"). GOTO out of an IF block leaves a stale if_stack entry — cleared on next RUN by clear_all_stacks().
ON ERROR GOTO <line> registers an error handler; ON ERROR GOTO 0 disables it. When a runtime error occurs during program execution, _handle_flow_control() in program_executor.py intercepts the error (if a handler is registered and not already in an error handler), saves error state, and jumps to the handler line.
State fields on CoCoBasic: on_error_goto_line, error_number (ERR), error_line (ERL), error_resume_position, in_error_handler. All reset by clear_interpreter_state().
RESUME / RESUME NEXT / RESUME <line> return resume / resume_next / jump directives handled by _handle_flow_control(). ERR and ERL are read-only pseudo-variables exposed in visit_variable().
LOCAL var1, var2, ... inside a GOSUB subroutine saves the listed variables' current values. On RETURN, saved values are restored. This prevents subroutine variable collisions — the main pain point when all variables are global.
PRIVATE var1, var2, ... works like LOCAL but additionally initializes each variable to 0 (numeric) or "" (string). This creates clean scratch space — variables start fresh and don't leak after RETURN.
- GOSUB pushes an empty frame onto
local_stack; LOCAL/PRIVATE append (name, saved_value) entries to the current frame - PRIVATE also sets each variable to its default value (0 or "") after saving
- RETURN pops the frame and restores variables in reverse order; variables that didn't exist before LOCAL/PRIVATE are removed
- LOCAL/PRIVATE outside GOSUB is a runtime error
local_stackis managed alongsidecall_stackinclear_all_stacks(),save_execution_state(),restore_execution_state()execute_localandexecute_privatelive incontrol_flow.py(registry commands)
INPUT pauses execution by returning {'type': 'input_request', ...}. Variable targets are described by dicts: {'name': 'V', 'array': True, 'indices': [1]} or {'name': 'X', 'array': False}. After storing the value via store_input_value(var_desc, value), call continue_program_execution() to resume.
StatementSplitter in text_utils.py owns all statement-splitting and argument-splitting logic:
split_on_delimiter()— split on colons (or custom delimiter), respecting quoted strings.split_args()— split on commas, respecting parentheses and quotes. The single shared comma-splitter for the entire codebase (replaces all former per-module_split_argsvariants).split_on_delimiter_paren_aware()— generalized version ofsplit_args()for non-comma delimiters.is_rem_line()— guards against splitting REM comments.has_control_keyword()— detects IF/FOR/WHILE/DO at the start of a line.CONTROL_KEYWORDS— canonical tuple of control-flow keyword prefixes.
Never duplicate this logic — always call through to StatementSplitter.
process_command() intercepts LINE INPUT before the registry (since LINE would otherwise match the graphics command), then detects multi-statement lines early and routes them to process_line(). process_line() then takes one of three paths:
- REM →
process_statement()directly (never split). - Control structure + colons (IF/FOR/WHILE/DO) → AST conversion →
_execute_converted_as_temporary_program()→run_program(). - Plain multi-statement → sequential
process_statement()loop, stopping on jump or error.
expand_line_to_sublines() in core.py splits stored lines once at entry time. REM lines and single-line IF/THEN are kept whole; everything else is split via StatementSplitter.split_on_delimiter(). Each subline is then pre-compiled by _store_subline():
- DATA → values pre-collected into
data_valuesinline, then compiled asCompiledCommand(handler is a no-op at runtime) - Multi-line IF (
IF cond THEN) → condition pre-parsed to AST, stored asCompiledMultiLineIf(condition_ast)— executor evaluates condition and pushes toif_stack - File I/O (PRINT#, INPUT#, LINE INPUT) → stays as string (AST drops
#) - AST-parseable (PRINT, FOR, GOTO, assignments, etc.) → stored as AST node
- Registry command (NEXT, WEND, LOOP, ELSE, ENDIF, DIM, SOUND, etc.) → stored as
CompiledCommand(handler, args, keyword)— skips tokenization and registry lookup at runtime
At runtime, _execute_statements_loop() dispatches four ways: CompiledMultiLineIf → condition eval + if_stack, CompiledCommand → direct handler call, AST node → ast_evaluator.visit(), string → process_statement() (only file I/O reaches this path). Skip methods (_skip_to_next, _skip_to_keyword, _skip_if_or_else_block) check CompiledCommand.keyword, CompiledMultiLineIf, and strings.
On RUN, data_statements is built from data_values in sorted order — no re-parsing needed. Parsing logic lives in DataCommands.parse_data_values() (static method).
process_*: internal system (process_command,process_statement)execute_*: registry commands (execute_next,execute_wend)visit_*: AST execution (visit_for_statement,visit_goto_statement)
functions.pyowns all BASIC functions — never duplicate elsewhere- Graphics commands go in
graphics.py, DIM/arrays invariables.py, control-flow closing commands incontrol_flow.py, DATA/READ/RESTORE indata_commands.py GPRINT(x,y),"text"[,color]draws text on the graphics screen using a 4x6 pixel font. Implemented as registry command ingraphics.py(server emits{'type': 'gtext', ...}), rendered client-side bydrawText()usingGPRINT_FONTbitmap data indual_monitor.js- Graphics helpers:
self.emulator.eval_int(expr)for expression→int,_syntax_error(msg, suggestions)for error responses - LINE coordinate pair syntax:
CommandRegistry.is_coordinate_pair_syntax()andparse_line_coordinates()handle(x1,y1)-(x2,y2)with optional spaces around the dash - System OK messages use
_system_ok()(tagged with'source': 'system') - File-creating tests must use autouse temp directory fixtures — never write to real
programs/ - All errors use
error_context.syntax_error()/runtime_error()with 2-3 suggestions.syntax_error()auto-prefixes "SYNTAX ERROR:" — don't add it manually. - Use
error_response(error)andtext_response(text)fromerror_contextto build response lists — never hand-build[{'type': 'error', ...}]or[{'type': 'text', ...}] - Use
basic_truthy(value)fromast_nodesfor BASIC boolean conversion — never inline theisinstancecheck - Use
check_reserved_name(name)onCoCoBasicto guard against reserved function names in assignments and DIM - Use
clear_all_stacks(),save_execution_state(),restore_execution_state()for stack/state management source venv/bin/activatebefore running anything- Tests:
python -m pytest(slow tests excluded by default via-m "not slow"in pytest.ini) - Run slow tests explicitly:
python -m pytest -m slow - Run all tests:
python -m pytest -m "" - Server logs:
./monitor_server_logs.shtailslogs/server_latest.log(created by./start_server_with_logging.sh)