WIP: offchain-start #14

Draft
GlowingOutlaw wants to merge 10 commits from offchain-start into main
Showing only changes of commit 46683c4e21 - Show all commits
+83 -56
View File
@@ -1,39 +1,50 @@
//! # ast.rs //! src/compiler/ast.rs
//! This module defines the **Abstract Syntax Trees (ASTs)** for our Push3-like language. //!
//! //! This module defines an **abstract** untyped AST for our Push3-like language,
//! Currently, we provide only an **untyped AST** (`UntypedAst`), which allows any sequence //! plus a mapping for how to encode opcodes as bytes. We do *not* hardcode numeric
//! of instructions, sublists, or integer literals. This is easy to parse and mutate, but //! values in the `OpCode` enum. Instead, we provide a trait that can map
//! **does not** enforce correctness of operand types (for example, `INTEGER_PLUS` might //! opcodes to bytes. This allows us to expand or change instructions easily
//! appear even if there's only one integer on the stack). //! (e.g., when the on-chain interpreter adds new opcodes or changes their IDs).
//!
//! ## Future: Typed AST
//! We **plan** to add a **typed AST** implementation, where each node ensures the correct
//! number and type of arguments at compile-time. This would prevent invalid programs from
//! being constructed in the first place. However, that is not implemented here yet—it’s
//! an **eventual goal** for more advanced genetic programming or verification workflows.
use serde::{Deserialize, Serialize}; /// A trait describing how to convert an AST into Push3 bytecode.
// use std::fmt;
/// A trait describing the core operation: converting an AST to Push3 bytecode.
/// ///
/// We might later add more methods here (like parsing from bytecode or mutation). /// This is deliberately minimal for now. In the future, we could add more methods
/// (like parsing from bytecode or AST mutation).
pub trait Push3Ast { pub trait Push3Ast {
/// Convert this AST into a bytecode vector that the on-chain interpreter
/// can parse and execute.
fn to_bytecode(&self) -> Vec<u8>; fn to_bytecode(&self) -> Vec<u8>;
} }
/// The untyped AST: each node is either an integer literal, a single instruction (no children), /// A trait that **maps** each [`OpCode`] variant to its **single-byte** representation.
/// or a sublist (which recursively contains more AST nodes). ///
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] /// By centralizing this, we can easily add or reorder instructions without rewriting
/// big chunks of code everywhere. For example, if we add `OpCode::MyNewOp`, we just
/// update this trait’s logic.
pub trait OpCodeMapping {
/// Given an `OpCode` enum variant, return the corresponding single `u8`
/// that the interpreter expects.
fn opcode_byte(&self, op: &OpCode) -> u8;
// If you need reverse-lookup (byte => OpCode), you could add a method like:
// fn from_byte(&self, b: u8) -> Option<OpCode>;
// for now, we omit it since we only do forward mapping in this file.
}
/// Our untyped AST node:
/// - `IntLiteral(i32)` holds a literal integer,
/// - `Instruction(OpCode)` holds one opcode,
/// - `Sublist(Vec<UntypedAst>)` holds a collection of nested AST nodes.
#[derive(Debug, Clone, PartialEq)]
pub enum UntypedAst { pub enum UntypedAst {
IntLiteral(i32), IntLiteral(i32),
Instruction(OpCode), Instruction(OpCode),
Sublist(Vec<UntypedAst>), Sublist(Vec<UntypedAst>),
} }
/// The set of push3 instructions recognized in our untyped AST. /// An **abstract** set of opcodes. We do *not* assign numeric values here.
/// Each corresponds to a single token byte in `to_bytecode`. /// Instead, see [`OpCodeMapping::opcode_byte`] for how we convert them to bytes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub enum OpCode { pub enum OpCode {
Noop, Noop,
Plus, Plus,
@@ -41,63 +52,79 @@ pub enum OpCode {
Mult, Mult,
Dup, Dup,
Pop, Pop,
// Add new instructions here as needed. E.g.:
// MyNewOp, etc.
} }
/// Implementation of `Push3Ast` for the **untyped** AST. impl UntypedAst {
/// /// Encode this AST into bytecode, using a provided [`OpCodeMapping`].
/// This produces bytecode recognized by the Push3Interpreter contract: ///
/// /// This method is more flexible than `to_bytecode()`, because you can pass in
/// - `0x00` => NOOP /// *any* mapping if needed. The method used by the trait’s `to_bytecode()`
/// - `0x01` => INTEGER_PLUS /// relies on the global `DEFAULT_OP_MAPPING`.
/// - `0x02` => INT_LITERAL => next 4 bytes (little-endian i32) pub fn to_bytecode_with_mapping<M: OpCodeMapping>(&self, mapping: &M) -> Vec<u8> {
/// - `0x03` => SUBLIST => next 2 bytes (big-endian `u16` length), then the sublist contents
/// - `0x04` => INTEGER_MINUS
/// - `0x05` => INTEGER_MULT
/// - `0x06` => INTEGER_DUP
/// - `0x07` => INTEGER_POP
impl Push3Ast for UntypedAst {
fn to_bytecode(&self) -> Vec<u8> {
match self { match self {
// For an integer literal, we write the “tag byte” for int-literal, then 4 bytes (LE).
UntypedAst::IntLiteral(val) => { UntypedAst::IntLiteral(val) => {
// 0x02 => INT_LITERAL => then 4 bytes little-endian // Hardcode 0x02 => INT_LITERAL.
// You *could* put that in the mapping if you want to make that flexible, too.
let mut bytes = Vec::with_capacity(1 + 4); let mut bytes = Vec::with_capacity(1 + 4);
bytes.push(0x02); bytes.push(0x02);
bytes.extend_from_slice(&val.to_be_bytes()); bytes.extend_from_slice(&val.to_be_bytes());
bytes bytes
} }
UntypedAst::Instruction(op) => { UntypedAst::Instruction(op) => {
// Single byte representing the opcode // Use the mapping to find the correct opcode byte:
let byte = match op { let b = mapping.opcode_byte(op);
OpCode::Noop => 0x00, vec![b]
OpCode::Plus => 0x01,
OpCode::Minus => 0x04,
OpCode::Mult => 0x05,
OpCode::Dup => 0x06,
OpCode::Pop => 0x07,
};
vec![byte]
} }
UntypedAst::Sublist(children) => { UntypedAst::Sublist(children) => {
// 0x03 => SUBLIST // Hardcode 0x03 => SUBLIST, then big-endian length, then child payload
// Then 2 bytes for the length (big-endian `u16`),
// Then the concatenated child bytecode.
let mut payload = Vec::new(); let mut payload = Vec::new();
for child in children { for child in children {
let child_bytes = child.to_bytecode(); let child_bytes = child.to_bytecode_with_mapping(mapping);
payload.extend_from_slice(&child_bytes); payload.extend(child_bytes);
} }
let sub_len = payload.len() as u16; let sub_len = payload.len() as u16;
let mut bytes = Vec::with_capacity(1 + 2 + payload.len()); let mut bytes = Vec::with_capacity(1 + 2 + payload.len());
bytes.push(0x03); bytes.push(0x03);
bytes.extend_from_slice(&sub_len.to_be_bytes()); // big-endian length bytes.extend_from_slice(&sub_len.to_be_bytes()); // big-endian length
bytes.extend_from_slice(&payload); bytes.extend(payload);
bytes bytes
} }
} }
} }
} }
/// For convenience, we implement `Push3Ast` using a *default* mapping.
impl Push3Ast for UntypedAst {
fn to_bytecode(&self) -> Vec<u8> {
self.to_bytecode_with_mapping(&DEFAULT_OP_MAPPING)
}
}
/// A default mapping that corresponds to your current on-chain byte definitions.
///
/// This way, if your interpreter changes (e.g., `Minus` becomes 0x0A),
/// you just update this mapping.
pub struct DefaultOpCodeMapping;
impl OpCodeMapping for DefaultOpCodeMapping {
fn opcode_byte(&self, op: &OpCode) -> u8 {
match op {
OpCode::Noop => 0x00, // 0x00 => NOOP
OpCode::Plus => 0x01, // 0x01 => INTEGER_PLUS
OpCode::Minus => 0x04, // 0x04 => INTEGER_MINUS
OpCode::Mult => 0x05, // 0x05 => INTEGER_MULT
OpCode::Dup => 0x06, // 0x06 => INTEGER_DUP
OpCode::Pop => 0x07, // 0x07 => INTEGER_POP
}
}
}
/// A convenient global `const` or `static` for quick usage.
pub const DEFAULT_OP_MAPPING: DefaultOpCodeMapping = DefaultOpCodeMapping;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// S-Expression Parsing Helpers // S-Expression Parsing Helpers
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------