This document provides a comprehensive overview of FerrisScript’s architecture, design decisions, and implementation details. It’s intended for contributors who want to understand how the language works internally and where to make changes.
FerrisScript is a scripting language designed for use with the Godot game engine. It provides a Rust-like syntax with strong type checking, compiles to an abstract syntax tree (AST), and executes via a tree-walking interpreter.
┌─────────────────┐
│ .ferris files │ User writes game scripts
└────────┬────────┘
│
▼
┌─────────────────┐
│ Lexer │ Source code → Tokens
└────────┬────────┘
│
▼
┌─────────────────┐
│ Parser │ Tokens → Abstract Syntax Tree (AST)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Type Checker │ Validates types and semantics
└────────┬────────┘
│
▼
┌─────────────────┐
│ Runtime │ Tree-walking interpreter executes AST
└────────┬────────┘
│
▼
┌─────────────────┐
│ Godot Bindings │ GDExtension connects to Godot nodes
└─────────────────┘
crates/compiler): Lexer, parser, type checker, AST definitionscrates/runtime): Tree-walking interpreter, variable scoping, builtin functionscrates/godot_bind): GDExtension integration, node property access, lifecycle hooksThe project is organized as a Rust workspace with three main crates:
FerrisScript/
├── crates/
│ ├── compiler/ # Compilation pipeline (543 tests)
│ │ ├── src/
│ │ │ ├── lexer.rs # Tokenization
│ │ │ ├── parser.rs # Recursive descent parser
│ │ │ ├── type_checker.rs # Type checking and validation
│ │ │ ├── ast.rs # AST node definitions
│ │ │ ├── error_code.rs # Error codes and messages
│ │ │ └── lib.rs # Public API (compile function)
│ │ └── Cargo.toml
│ │
│ ├── runtime/ # Interpreter (110 tests)
│ │ ├── src/
│ │ │ └── lib.rs # Environment, value types, execution
│ │ ├── tests/
│ │ │ └── inspector_sync_test.rs # Integration tests
│ │ └── Cargo.toml
│ │
│ ├── godot_bind/ # Godot GDExtension (11 tests, 10 ignored)
│ │ ├── src/
│ │ │ ├── lib.rs # FerrisScriptNode, Godot callbacks
│ │ │ ├── export_info_functions.rs # @export annotation support
│ │ │ └── property_export.rs # PropertyInfo generation
│ │ └── Cargo.toml
│ │
│ └── test_harness/ # Testing infrastructure (38 tests)
│ ├── src/
│ │ ├── main.rs # ferris-test CLI entry point
│ │ ├── lib.rs # Public test harness API
│ │ ├── godot_cli.rs # Godot process management
│ │ ├── output_parser.rs # Test output parsing
│ │ └── test_runner.rs # Test execution engine
│ └── Cargo.toml
│
├── examples/ # 26 example .ferris scripts
│ ├── hello.ferris
│ ├── move.ferris
│ ├── signals.ferris
│ ├── struct_literals_*.ferris # Godot type construction demos
│ └── node_query_*.ferris # Scene tree query examples
│
├── godot_test/ # Godot test project (17 integration tests)
│ ├── project.godot
│ ├── ferrisscript.gdextension
│ └── scripts/
│ ├── export_properties_test.ferris
│ ├── signal_test.ferris
│ └── process_test.ferris
│
├── extensions/ # Editor extensions
│ └── vscode/ # VS Code extension (v0.0.4)
│ ├── syntaxes/ # Syntax highlighting
│ ├── snippets/ # Code snippets
│ └── language-configuration.json
│
├── docs/ # Documentation
│ ├── testing/ # Testing guides and matrices
│ │ ├── README.md # Testing hub
│ │ └── TESTING_GUIDE.md # Comprehensive guide
│ ├── planning/ # Version roadmaps
│ ├── ARCHITECTURE.md # This file
│ ├── DEVELOPMENT.md # Dev workflow
│ └── CONTRIBUTING.md # Contribution guide
│
├── Cargo.toml # Workspace configuration
└── README.md # Project overview
godot_bind
├── depends on: ferrisscript_compiler
├── depends on: ferrisscript_runtime
└── depends on: godot (GDExtension bindings)
runtime
└── depends on: ferrisscript_compiler (AST types)
compiler
└── (no internal dependencies)
The compiler transforms FerrisScript source code into a validated AST through three stages:
File: crates/compiler/src/lexer.rs
The lexer scans the source code character-by-character and produces a stream of tokens.
fn, let, mut, if, else, while, return, true, falsefoo), Numbers (42, 3.14), Strings ("hello")(, ), {, }, ,, ;, ., :+, -, *, /, =, ==, !=, <, <=, >, >=, &&, ||, !, +=, -=// Input:
fn hello() {
print("Hello, world!");
}
// Output (tokens):
[Fn, Ident("hello"), LParen, RParen, LBrace,
Ident("print"), LParen, StringLit("Hello, world!"), RParen, Semicolon,
RBrace, Eof]
==, !=, <=, >=, &&, ||, +=, -= are recognized using lookahead\n, \t, \", \\)//) and block (/* */) comments are skippedFile: crates/compiler/src/parser.rs
The parser uses recursive descent to build an AST from the token stream.
Program → (GlobalVar | Function)*
GlobalVar → 'let' 'mut'? Ident (':' Type)? '=' Expr ';'
Function → 'fn' Ident '(' Params? ')' ('->' Type)? Block
Params → Param (',' Param)*
Param → Ident ':' Type
Stmt → LetStmt | AssignStmt | ReturnStmt | WhileStmt | IfStmt | ExprStmt
LetStmt → 'let' 'mut'? Ident (':' Type)? '=' Expr ';'
AssignStmt → Expr ('+=' | '-=' | '=') Expr ';'
ReturnStmt → 'return' Expr? ';'
WhileStmt → 'while' Expr Block
IfStmt → 'if' Expr Block ('else' (IfStmt | Block))?
ExprStmt → Expr ';'
Expr → LogicalOr
LogicalOr → LogicalAnd ('||' LogicalAnd)*
LogicalAnd → Equality ('&&' Equality)*
Equality → Comparison (('==' | '!=') Comparison)*
Comparison → Term (('<' | '<=' | '>' | '>=') Term)*
Term → Factor (('+' | '-') Factor)*
Factor → Unary (('*' | '/') Unary)*
Unary → ('!' | '-') Unary | Call
Call → Primary ('(' Args? ')' | '.' Ident)*
Primary → Number | String | 'true' | 'false' | 'self' | Ident | '(' Expr ')'
File: crates/compiler/src/ast.rs
Key AST node types:
Example AST structure:
Program {
global_vars: [],
functions: [
Function {
name: "hello",
params: [],
return_type: None,
body: [
ExprStmt(
Call {
callee: Ident("print"),
args: [StringLit("Hello, world!")]
}
)
]
}
]
}
The parser does not attempt error recovery. On the first parse error, it returns immediately with an error message including:
This is sufficient for game scripting where scripts are small and errors are fixed immediately.
File: crates/compiler/src/type_checker.rs
The type checker validates the AST before execution.
mutself is only used inside functions (implies node context)FerrisScript has a gradual type system:
let x = 5 or let x: int = 5)int to float for arithmetic)Supported types:
int, float, bool, stringVector2nil (unit type)Example type checking:
// Valid
fn add(a: int, b: int) -> int {
return a + b;
}
// Error: wrong parameter count
add(1, 2, 3); // Type checker error: Expected 2 arguments, found 3
// Error: return type mismatch
fn get_name() -> string {
return 42; // Type checker error: Expected string, got int
}
File: crates/runtime/src/lib.rs
The runtime is a tree-walking interpreter that directly executes the AST.
pub enum Value {
Int(i32),
Float(f32),
Bool(bool),
String(String),
Vector2 { x: f32, y: f32 },
Nil,
SelfObject, // Represents the Godot node (self)
}
The Env struct manages:
Function)print, sqrt)self.position)// Example: while loop with local variable
let global = 10; // Scope 0 (global)
fn process() {
let x = 5; // Scope 1 (function)
while x > 0 {
let temp = x * 2; // Scope 2 (while block)
x -= 1;
}
// Scope 2 popped, temp no longer accessible
}
// Scope 1 popped, x no longer accessible
The execute function evaluates statements:
The eval_expr function evaluates expressions recursively:
+, -, *, / (with int/float coercion)==, !=, <, <=, >, >=&&, || (short-circuit evaluation)! (logical not), - (negation)self.property → callback to GodotRegistered in Env::new():
Vector2 { x, y }Example builtin function signature:
fn builtin_print(args: &[Value]) -> Result<Value, String> {
// Print all arguments separated by spaces
// Return Value::Nil
}
File: crates/godot_bind/src/lib.rs
FerrisScript integrates with Godot via GDExtension (Godot 4’s native extension system).
Godot Engine
│
├── Loads .gdextension file (metadata)
│
├── Loads native .dll/.so/.dylib (Rust compiled)
│
└── Registers GDExtension classes
│
└── FerrisScriptNode (Node2D subclass)
#[derive(GodotClass)]
#[class(base=Node2D)]
pub struct FerrisScriptNode {
base: Base<Node2D>,
#[export(file = "*.ferris")]
script_path: GString, // Path to .ferris file (e.g., "res://scripts/hello.ferris")
env: Option<Env>, // Runtime environment
program: Option<ast::Program>, // Compiled AST
script_loaded: bool,
}
FerrisScript supports the following Godot lifecycle callbacks:
| Callback | When Called | Parameters | Purpose |
|---|---|---|---|
_ready() |
Node enters scene tree | None | Initialization |
_process(delta) |
Every frame | delta: f32 |
Frame updates |
_physics_process(delta) |
Every physics tick | delta: f32 |
Physics updates |
_input(event) |
Input event received | event: InputEvent |
Input handling |
_unhandled_input(event) |
Unhandled input | event: InputEvent |
Fallback input |
_enter_tree() |
Node enters tree | None | Tree entry |
_exit_tree() |
Node exits tree | None | Cleanup |
_ready() Execution Flow.ferris file from script_pathcompile(source))_ready() function in script (if defined)_process(delta: f32) Execution Flow_process(delta) function in script (if defined)FerrisScript supports Godot Inspector integration via @export annotations:
// Basic export
@export let speed: f32 = 100.0;
// Range hint (min, max) - clamps values in Inspector
@export(range, 0.0, 10.0) let health: f32 = 5.0;
// Enum hint - dropdown selector
@export(enum, "Idle", "Walk", "Run") let state: String = "Idle";
// File hint - file picker
@export(file, "*.png", "*.jpg") let texture_path: String = "";
// Multiline hint - text area
@export(multiline) let description: String = "Default text";
// Color hint - color picker
@export(color_no_alpha) let team_color: Color = Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
Implementation (crates/godot_bind/src/export_info_functions.rs):
PropertyInfo objects_get_property_list()_get() and _set() for Inspector integrationDeclare and emit custom signals visible in Godot Inspector:
// Declare at file scope
signal health_changed(new_health: f32);
signal player_died();
// Emit in any function
fn take_damage(amount: f32) {
health = health - amount;
emit_signal("health_changed", health);
if health <= 0.0 {
emit_signal("player_died");
}
}
Implementation:
signal declarations during compilationadd_user_signal()emit_signal() calls to Godot’s signal emissionAccess scene tree nodes at runtime:
let player = get_node("Player"); // Get child node
let parent = get_parent(); // Get parent node
let child = find_child("Enemy", true); // Find descendant (recursive)
if has_node("UI/HealthBar") { // Check node exists
let health_bar = get_node("UI/HealthBar");
}
Implementation (crates/runtime/src/lib.rs):
"Child") and nested paths ("UI/HUD")Construct Godot types directly with field syntax:
// Vector2
let pos = Vector2 { x: 100.0, y: 200.0 };
// Color (RGBA)
let red = Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
// Rect2
let rect = Rect2 {
position: Vector2 { x: 0.0, y: 0.0 },
size: Vector2 { x: 100.0, y: 50.0 }
};
// Transform2D
let transform = Transform2D {
position: Vector2 { x: 100.0, y: 200.0 },
rotation: 0.0,
scale: Vector2 { x: 1.0, y: 1.0 }
};
Implementation (crates/compiler/src/parser.rs, crates/runtime/src/lib.rs):
TypeName { field: value, ... }gdext APIChallenge: FerrisScript runtime needs to access/modify Godot node properties.
Solution: Thread-local storage + callbacks
thread_local! {
static NODE_POSITION: RefCell<Option<Vector2>> = const { RefCell::new(None) };
}
// Before calling script function:
NODE_POSITION.with(|pos| *pos.borrow_mut() = Some(node.get_position()));
// Inside script:
self.position.x += 10.0; // Modifies thread-local storage
// After script function returns:
let new_pos = NODE_POSITION.with(|pos| pos.borrow().unwrap());
node.set_position(new_pos);
Why thread-local?
Send + Sync (cannot cross thread boundaries)&mut selfCurrently supported self properties:
self.position (Vector2): Node position in 2D spaceTo add more properties, see Extension Points.
Alternatives considered:
Reasons:
Trade-offs:
Future: Could add bytecode VM or JIT in v1.0 if performance becomes an issue.
Alternatives considered:
.ferris → .gd filesReasons:
Trade-offs:
FerrisScript currently has no dynamic memory allocation in scripts:
Env hashmapsFuture: If we add closures, we’d need:
Rc)Currently not needed for game scripting use cases.
Alternatives considered:
end keywordsReasons:
let x: int = 5) fit game scriptingmut keyword makes mutability explicitvar vs. const distinctionTrade-offs:
This section explains how to extend FerrisScript with new features.
Example: Add % (modulo) operator
Add token (lexer.rs):
pub enum Token {
// ... existing tokens ...
Percent, // %
}
// In tokenize():
'%' => tokens.push(Token::Percent),
Add AST node (ast.rs):
pub enum BinaryOp {
// ... existing ops ...
Modulo,
}
Add parsing (parser.rs):
fn parse_factor(&mut self) -> Result<Expr, String> {
// ... existing code ...
while matches!(self.current(), Token::Star | Token::Slash | Token::Percent) {
let op = match self.advance() {
Token::Star => BinaryOp::Multiply,
Token::Slash => BinaryOp::Divide,
Token::Percent => BinaryOp::Modulo,
_ => unreachable!(),
};
// ... rest of parsing ...
}
}
Add evaluation (runtime/lib.rs):
BinaryOp::Modulo => {
let left_int = left.to_int().ok_or("Modulo requires int")?;
let right_int = right.to_int().ok_or("Modulo requires int")?;
Value::Int(left_int % right_int)
}
Add type checking (type_checker.rs):
BinaryOp::Modulo => {
check_int_operands(left, right)?;
Ok(Type::Int)
}
Add tests (compiler/lib.rs, runtime/lib.rs):
#[test]
fn test_modulo() {
let source = "fn main() { return 10 % 3; }";
let program = compile(source).unwrap();
let result = call_function(&mut env, "main", &[]).unwrap();
assert_eq!(result, Value::Int(1));
}
Example: Add floor(x: float) -> int function
Implement the function (runtime/lib.rs):
fn builtin_floor(args: &[Value]) -> Result<Value, String> {
if args.len() != 1 {
return Err("floor expects 1 argument".to_string());
}
let val = args[0].to_float()
.ok_or("floor expects a number")?;
Ok(Value::Int(val.floor() as i32))
}
Register in Env (runtime/lib.rs, in Env::new()):
env.builtin_fns.insert("floor".to_string(), builtin_floor);
Add tests:
#[test]
fn test_floor() {
let source = "fn main() { return floor(3.7); }";
let program = compile(source).unwrap();
let result = call_function(&mut env, "main", &[]).unwrap();
assert_eq!(result, Value::Int(3));
}
Example: Add Color { r, g, b, a } type
Add to Value enum (runtime/lib.rs):
pub enum Value {
// ... existing variants ...
Color { r: f32, g: f32, b: f32, a: f32 },
}
Add type name (type_checker.rs):
pub enum Type {
// ... existing types ...
Color,
}
Add constructor builtin:
fn builtin_color(args: &[Value]) -> Result<Value, String> {
if args.len() != 4 {
return Err("Color expects 4 arguments".to_string());
}
let r = args[0].to_float().ok_or("Color expects numbers")?;
let g = args[1].to_float().ok_or("Color expects numbers")?;
let b = args[2].to_float().ok_or("Color expects numbers")?;
let a = args[3].to_float().ok_or("Color expects numbers")?;
Ok(Value::Color { r, g, b, a })
}
Add Godot conversion (godot_bind/lib.rs):
// In property getter/setter:
"modulate" => {
if let Value::Color { r, g, b, a } = value {
let godot_color = godot::prelude::Color::from_rgba(r, g, b, a);
// Set on Godot node
}
}
Example: Add self.rotation (float) property
Add thread-local storage (godot_bind/lib.rs):
thread_local! {
static NODE_ROTATION: RefCell<Option<f32>> = const { RefCell::new(None) };
}
Add to property getter:
fn get_node_property_tls(property_name: &str) -> Result<Value, String> {
match property_name {
// ... existing properties ...
"rotation" => {
NODE_ROTATION.with(|rot| {
rot.borrow().map(|r| Value::Float(r))
.ok_or_else(|| "Node rotation not available".to_string())
})
}
_ => Err(format!("Property '{}' not supported", property_name)),
}
}
Add to property setter:
fn set_node_property_tls(property_name: &str, value: Value) -> Result<(), String> {
match property_name {
// ... existing properties ...
"rotation" => {
if let Value::Float(r) = value {
NODE_ROTATION.with(|rot| *rot.borrow_mut() = Some(r));
Ok(())
} else {
Err(format!("Expected float for rotation, got {:?}", value))
}
}
_ => Err(format!("Property '{}' not supported", property_name)),
}
}
Update _process hook (godot_bind/lib.rs):
// Before calling script function:
NODE_ROTATION.with(|rot| *rot.borrow_mut() = Some(self.base().get_rotation()));
// After script function:
if let Some(new_rot) = NODE_ROTATION.with(|rot| *rot.borrow()) {
self.base_mut().set_rotation(new_rot);
}
Strengths:
Bottlenecks:
self.position.x)Currently no benchmarks. To add:
benches/ directorycriterion.rs for micro-benchmarksSuggested benchmarks:
FerrisScript uses a 4-layer testing strategy to ensure quality across the stack:
┌─────────────────────────────────────────────┐
│ Layer 4: Manual Testing (Godot Editor) │ ← Feature validation
├─────────────────────────────────────────────┤
│ Layer 3: Integration Tests (.ferris) │ ← End-to-end behavior
├─────────────────────────────────────────────┤
│ Layer 2: GDExtension Tests (GDScript) │ ← Godot bindings
├─────────────────────────────────────────────┤
│ Layer 1: Unit Tests (Rust) │ ← Pure logic
└─────────────────────────────────────────────┘
| Layer | Type | Count | Location | Purpose |
|---|---|---|---|---|
| Layer 1 | Unit (Compiler) | 543 | crates/compiler/src/ |
Lexer, parser, type checker |
| Layer 1 | Unit (Runtime) | 110 | crates/runtime/src/ |
Interpreter, execution engine |
| Layer 1 | Unit (GDExtension) | 11 | crates/godot_bind/src/ |
Godot bindings (10 ignored*) |
| Layer 1 | Unit (Test Harness) | 38 | crates/test_harness/src/ |
ferris-test CLI |
| Layer 2 | GDExtension | N/A | godot_test/scripts/*.gd |
PropertyInfo, signals |
| Layer 3 | Integration | 15+ | godot_test/scripts/*.ferris |
End-to-end workflows |
| Layer 4 | Manual | N/A | Godot Editor | Feature validation |
| Total | 843 | ~82% coverage |
* Some tests require Godot runtime initialization and are covered by integration tests instead.
Purpose: Test pure logic without Godot dependencies
Example (compiler/src/lib.rs):
#[test]
fn test_parse_export_annotation() {
let source = "@export let speed: f32 = 100.0;";
let result = compile(&source);
assert!(result.is_ok());
assert!(result.unwrap().annotations.len() == 1);
}
Running:
cargo test --workspace # All unit tests
cargo test -p ferrisscript_compiler # Compiler only
cargo test -p ferrisscript_runtime # Runtime only
Purpose: Test Rust code requiring Godot runtime (godot::init())
Challenges: Many GDExtension functions require Godot initialization, which can’t run in standard unit tests. Solution: Mark as #[ignore] and cover via Layer 3 integration tests.
Example (godot_bind/src/lib.rs):
#[test]
#[ignore = "requires Godot runtime - tested via ferris-test"]
fn test_export_range_property() {
// Test PropertyInfo generation for @export(range)
}
Purpose: End-to-end testing with real Godot runtime
Tool: ferris-test CLI (headless Godot runner)
Example (godot_test/scripts/signal_test.ferris):
// TEST: signal_emission
// CATEGORY: integration
// EXPECT: success
// ASSERT: Signal emitted correctly
export fn _ready() {
print("[TEST_START]");
signal health_changed(f32);
emit_signal("health_changed", 100.0);
print("[PASS] Signal emitted successfully");
print("[TEST_END]");
}
Running:
ferris-test --all # Run all integration tests
ferris-test --script path.ferris # Run specific test
ferris-test --all --verbose # Verbose output
Test Scripts: Located in godot_test/scripts/:
export_properties_test.ferris - @export annotations with all hint typessignal_test.ferris - Signal declaration and emissionprocess_test.ferris - Lifecycle callbacksnode_query_*.ferris - Scene tree queriesstruct_literals_*.ferris - Godot type constructionPurpose: Feature validation and exploratory testing
Process:
cargo build --release.dll/.so/.dylib to Godot projectFerrisScriptNodescript_path to .ferris fileTest Project: godot_test/ - Complete Godot 4.x project with test scenes
ferris-test CLI (crates/test_harness/):
Configuration (ferris-test.toml):
godot_executable = "path/to/godot.exe"
project_path = "./godot_test"
timeout_seconds = 30
output_format = "console"
verbose = false
For comprehensive testing documentation, see:
.ferris codelexer.rsast.rsparser.rstype_checker.rsruntime/lib.rscargo test to compile exampleprint() in scriptruntime/lib.rsperf (Linux): perf record -g + perf reportcargo flamegraph (requires cargo-flamegraph)See CONTRIBUTING.md for how to contribute to FerrisScript. When making architectural changes:
For questions about the architecture:
For questions about Godot integration, see: