Hades is a tree-walk interpreter built entirely in Python. Source code inDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/ToberlerOhn/hades/llms.txt
Use this file to discover all available pages before exploring further.
.hds or .hd files travels through three sequential phases — tokenisation, parsing, and evaluation — before producing a result. Each phase is cleanly isolated in its own module, and the modules are wired together by a thin entry point in main.py. Understanding this layout is the fastest route to navigating the codebase.
The Three-Phase Pipeline
Tokenise — Source Text → Token List
Lexer(source).tokenize() scans the raw source string character-by-character and emits a flat list[Token]. Every token carries its type (a TT enum member), its literal value, and the line/column where it appeared. Whitespace and // comments are silently discarded during this step.Parse — Token List → AST
Parser(tokens).parse() consumes the token list with a recursive-descent strategy and returns a ProgramNode whose statements field contains a tree of AST node dataclasses. The parser uses precedence-climbing for binary expressions and never calls the lexer directly — the token list is fully materialised before parsing begins.Evaluate — AST → Result
Interpreter().evaluate(node) walks the AST from the root ProgramNode downward. For each node type it dispatches to a dedicated handler via the NODE_HANDLERS dict. Runtime state lives in a chain of Scope objects. The final return value of the last top-level statement is the program’s result.42 to stdout.Module Layout
main.py
The entry point. Reads the
.hds / .hd file, instantiates Lexer, Parser, and Interpreter in sequence, and wraps the whole run in try/except blocks that catch SyntaxError and InterpreterError, then format them with f_error() before printing and exiting with code 1. Passing -v / --verbose prints the raw token list and AST before execution.modules/lexer.py
Contains the
Lexer class. The public method tokenize() drives an internal get_next_token() loop until TT.EOF is reached. Exposes a pretty_print() debugging helper that groups tokens by source line.modules/parser.py
Contains the
Parser class. The public method parse() returns a ProgramNode. Internally STATEMENT_HANDLERS and PRIMARY_HANDLERS dispatch dicts route each token type to the right recursive method. Raises ParserError (a SyntaxError subclass) with line/column info on any syntactic mistake.modules/interpreter.py
Contains the
Interpreter class, the InterpreterError exception, and the ReturnSignal exception. The public method evaluate(node) dispatches via NODE_HANDLERS. Built-in functions (print, type, len) are registered in the BUILTINS dict and never appear in Scope.modules/ast_nodes.py
All AST node dataclasses live here — literals, operations, control-flow nodes,
FuncNode, ProgramNode. Each AST dataclass carries a reference to the originating Token so the interpreter can report accurate source positions. The runtime objects HadesFunction, HadesClass, and HadesInstance also live in this file; they are created at evaluation time and are not AST nodes.modules/tokens.py
Defines the
TT enum (all token type constants) and the Token dataclass (type, value, line, column). Both are imported by every other module — this is the only shared data contract across Lexer, Parser, and Interpreter.modules/scope.py
The
Scope class: a dict-backed variable environment with get(), set(), declare(), and has() methods. get() and set() both walk the parent chain automatically, so inner scopes can read and mutate variables declared in outer scopes. has() checks the current scope only and does not walk the parent chain.modules/helpers.py
Two utility functions.
match(string, pattern) wraps re.search into a boolean helper used by the lexer. f_error(error_type, message, source_text, line, column) formats a rich terminal error block — underlines the offending source line and places a red ^ caret at the error column — used by main.py.How Scope Works
Every variable in Hades lives inside aScope object. Scopes form a singly-linked list via their parent pointer, stretching from the current innermost block all the way back to the global scope.
| Situation | How it happens |
|---|---|
Block statement (if, while, for) | _eval_block() saves the old scope, creates Scope(parent=self.scope), and restores after |
| Function call | _call_function() creates Scope(parent=function.closure_scope), so closures see their definition-time environment |
for … in loop | _eval_forin() creates a fresh Scope per iteration to prevent the loop variable from leaking |
declare() always writes into the current scope, while set() searches upward and mutates the first scope that owns the name. This distinction is what allows inner scopes to shadow outer variables cleanly.How Errors Propagate
SyntaxError — raised by Lexer and Parser
SyntaxError — raised by Lexer and Parser
The
Lexer raises a plain SyntaxError via its RaiseError() helper. The Parser raises ParserError, a subclass of SyntaxError, which stores the offending token’s line and column as attributes. Both are caught in main.py’s except SyntaxError clause. The message is stripped of the internal " at (line, col)" suffix and passed to f_error() for pretty-printing.InterpreterError — raised by the Interpreter
InterpreterError — raised by the Interpreter
InterpreterError carries the offending Token directly so that its line and column can be extracted without parsing the message string. The interpreter raises it for undefined variables, type mismatches, division by zero, and invalid operands.