mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 17:28:00 +00:00
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1: - Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators - Parser: recursive descent with Pratt expression parsing (M1 subset) - Analyzer: symbol resolution, type checking, memory allocation - 6502 Assembler: full opcode encoding table (~150 valid combinations) - Code Generator: AST → 6502 instructions (direct, no IR for M1) - Runtime: NES hardware init, NMI handler, controller read, OAM DMA - Linker: NROM layout, vector table, palette loading, CHR data - ROM Builder: iNES header generation, PRG/CHR padding - CLI: `build` and `check` subcommands via clap 143 tests across all modules: - 22 lexer tests (literals, keywords, operators, error recovery) - 18 parser tests (expressions, statements, game structure, errors) - 7 analyzer tests (symbol resolution, memory allocation, transitions) - 30 assembler tests (every addressing mode, label resolution) - 7 codegen tests (var init, arithmetic, buttons, draw, comparisons) - 11 runtime tests (init sequence, NMI handler, controller read) - 10 ROM builder tests (iNES format, mirroring, banking, validation) - 5 linker tests (vector table, CHR data, palette loading) - 7 integration tests (end-to-end compilation, error detection) CI: GitHub Actions for check, fmt, clippy, test Pre-commit: script for local fmt + clippy + test validation https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
parent
1fca6864ac
commit
39ca246151
32 changed files with 6306 additions and 0 deletions
346
src/analyzer/mod.rs
Normal file
346
src/analyzer/mod.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::errors::{Diagnostic, ErrorCode};
|
||||
use crate::lexer::Span;
|
||||
use crate::parser::ast::*;
|
||||
|
||||
/// Symbol information stored in the scope.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Symbol {
|
||||
pub name: String,
|
||||
pub sym_type: NesType,
|
||||
pub is_const: bool,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
/// Memory assignment for a variable.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VarAllocation {
|
||||
pub name: String,
|
||||
pub address: u16,
|
||||
pub size: u16,
|
||||
}
|
||||
|
||||
/// Result of semantic analysis.
|
||||
pub struct AnalysisResult {
|
||||
pub symbols: HashMap<String, Symbol>,
|
||||
pub var_allocations: Vec<VarAllocation>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Analyze a parsed program for semantic errors.
|
||||
pub fn analyze(program: &Program) -> AnalysisResult {
|
||||
let mut analyzer = Analyzer {
|
||||
symbols: HashMap::new(),
|
||||
var_allocations: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
next_ram_addr: 0x0300, // $0300 is first usable RAM after OAM buffer
|
||||
next_zp_addr: 0x10, // $10 is first usable zero-page after reserved area
|
||||
};
|
||||
analyzer.analyze_program(program);
|
||||
|
||||
AnalysisResult {
|
||||
symbols: analyzer.symbols,
|
||||
var_allocations: analyzer.var_allocations,
|
||||
diagnostics: analyzer.diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
struct Analyzer {
|
||||
symbols: HashMap<String, Symbol>,
|
||||
var_allocations: Vec<VarAllocation>,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
next_ram_addr: u16,
|
||||
next_zp_addr: u8,
|
||||
}
|
||||
|
||||
impl Analyzer {
|
||||
fn analyze_program(&mut self, program: &Program) {
|
||||
// Register constants
|
||||
for c in &program.constants {
|
||||
self.register_const(c);
|
||||
}
|
||||
|
||||
// Register and allocate globals
|
||||
for var in &program.globals {
|
||||
self.register_var(var);
|
||||
}
|
||||
|
||||
// Register state-local variables
|
||||
for state in &program.states {
|
||||
for var in &state.locals {
|
||||
self.register_var(var);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate state references
|
||||
let state_names: Vec<&str> = program.states.iter().map(|s| s.name.as_str()).collect();
|
||||
|
||||
// Check start state exists
|
||||
if !state_names.contains(&program.start_state.as_str()) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0404,
|
||||
format!("start state '{}' is not defined", program.start_state),
|
||||
program.span,
|
||||
));
|
||||
}
|
||||
|
||||
// Type-check all state bodies
|
||||
for state in &program.states {
|
||||
if let Some(block) = &state.on_enter {
|
||||
self.check_block(block, &state_names);
|
||||
}
|
||||
if let Some(block) = &state.on_exit {
|
||||
self.check_block(block, &state_names);
|
||||
}
|
||||
if let Some(block) = &state.on_frame {
|
||||
self.check_block(block, &state_names);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_const(&mut self, c: &ConstDecl) {
|
||||
if self.symbols.contains_key(&c.name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0501,
|
||||
format!("duplicate declaration of '{}'", c.name),
|
||||
c.span,
|
||||
));
|
||||
return;
|
||||
}
|
||||
self.symbols.insert(
|
||||
c.name.clone(),
|
||||
Symbol {
|
||||
name: c.name.clone(),
|
||||
sym_type: c.const_type.clone(),
|
||||
is_const: true,
|
||||
span: c.span,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_var(&mut self, var: &VarDecl) {
|
||||
if self.symbols.contains_key(&var.name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0501,
|
||||
format!("duplicate declaration of '{}'", var.name),
|
||||
var.span,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let size = type_size(&var.var_type);
|
||||
let address = self.allocate_ram(size);
|
||||
|
||||
self.symbols.insert(
|
||||
var.name.clone(),
|
||||
Symbol {
|
||||
name: var.name.clone(),
|
||||
sym_type: var.var_type.clone(),
|
||||
is_const: false,
|
||||
span: var.span,
|
||||
},
|
||||
);
|
||||
|
||||
self.var_allocations.push(VarAllocation {
|
||||
name: var.name.clone(),
|
||||
address,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
fn allocate_ram(&mut self, size: u16) -> u16 {
|
||||
// For M1: simple linear allocator using zero-page for u8 vars
|
||||
if size == 1 && self.next_zp_addr < 0xFF {
|
||||
let addr = u16::from(self.next_zp_addr);
|
||||
self.next_zp_addr = self.next_zp_addr.wrapping_add(1);
|
||||
addr
|
||||
} else {
|
||||
let addr = self.next_ram_addr;
|
||||
self.next_ram_addr += size;
|
||||
addr
|
||||
}
|
||||
}
|
||||
|
||||
fn check_block(&mut self, block: &Block, state_names: &[&str]) {
|
||||
for stmt in &block.statements {
|
||||
self.check_statement(stmt, state_names);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_statement(&mut self, stmt: &Statement, state_names: &[&str]) {
|
||||
match stmt {
|
||||
Statement::VarDecl(var) => {
|
||||
self.register_var(var);
|
||||
if let Some(init) = &var.init {
|
||||
self.check_expr_type(init, &var.var_type);
|
||||
}
|
||||
}
|
||||
Statement::Assign(lvalue, _, expr, span) => {
|
||||
let ltype = self.lvalue_type(lvalue, *span);
|
||||
if let Some(lt) = ltype {
|
||||
self.check_expr_type(expr, <);
|
||||
}
|
||||
}
|
||||
Statement::If(cond, then_block, else_ifs, else_block, _) => {
|
||||
self.check_expr_type(cond, &NesType::Bool);
|
||||
self.check_block(then_block, state_names);
|
||||
for (cond, block) in else_ifs {
|
||||
self.check_expr_type(cond, &NesType::Bool);
|
||||
self.check_block(block, state_names);
|
||||
}
|
||||
if let Some(block) = else_block {
|
||||
self.check_block(block, state_names);
|
||||
}
|
||||
}
|
||||
Statement::While(cond, body, _) => {
|
||||
self.check_expr_type(cond, &NesType::Bool);
|
||||
self.check_block(body, state_names);
|
||||
}
|
||||
Statement::Loop(body, _) => {
|
||||
self.check_block(body, state_names);
|
||||
}
|
||||
Statement::Transition(name, span) => {
|
||||
if !state_names.contains(&name.as_str()) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0404,
|
||||
format!("transition to undefined state '{name}'"),
|
||||
*span,
|
||||
));
|
||||
}
|
||||
}
|
||||
Statement::Draw(draw) => {
|
||||
self.check_expr_type(&draw.x, &NesType::U8);
|
||||
self.check_expr_type(&draw.y, &NesType::U8);
|
||||
if let Some(frame) = &draw.frame {
|
||||
self.check_expr_type(frame, &NesType::U8);
|
||||
}
|
||||
}
|
||||
Statement::Return(Some(expr), _) => {
|
||||
// For M1, just validate the expression without checking return type
|
||||
let _ = self.infer_type(expr);
|
||||
}
|
||||
Statement::Call(name, _args, span) => {
|
||||
if !self.symbols.contains_key(name) {
|
||||
// Not a known function yet — for M1, just warn
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0502,
|
||||
format!("undefined function '{name}'"),
|
||||
*span,
|
||||
));
|
||||
}
|
||||
}
|
||||
Statement::Break(_)
|
||||
| Statement::Continue(_)
|
||||
| Statement::WaitFrame(_)
|
||||
| Statement::Return(None, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn lvalue_type(&self, lvalue: &LValue, _span: Span) -> Option<NesType> {
|
||||
match lvalue {
|
||||
LValue::Var(name) => self.symbols.get(name).map(|s| s.sym_type.clone()),
|
||||
LValue::ArrayIndex(name, _) => {
|
||||
self.symbols.get(name).and_then(|sym| match &sym.sym_type {
|
||||
NesType::Array(elem, _) => Some(elem.as_ref().clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_expr_type(&mut self, expr: &Expr, expected: &NesType) {
|
||||
let actual = self.infer_type(expr);
|
||||
if let Some(actual) = actual {
|
||||
// Allow numeric comparisons to produce bool
|
||||
if *expected == NesType::Bool && actual == NesType::Bool {
|
||||
return;
|
||||
}
|
||||
// For M1: be lenient about integer types in conditions
|
||||
// button reads produce bool
|
||||
if *expected == NesType::Bool {
|
||||
match expr {
|
||||
Expr::ButtonRead(..)
|
||||
| Expr::BinaryOp(
|
||||
_,
|
||||
BinOp::Eq
|
||||
| BinOp::NotEq
|
||||
| BinOp::Lt
|
||||
| BinOp::Gt
|
||||
| BinOp::LtEq
|
||||
| BinOp::GtEq,
|
||||
_,
|
||||
_,
|
||||
)
|
||||
| Expr::UnaryOp(UnaryOp::Not, _, _)
|
||||
| Expr::BinaryOp(_, BinOp::And | BinOp::Or, _, _) => return,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if actual != *expected {
|
||||
// Allow implicit u8/i8/u16 in assignments for M1 simplicity
|
||||
if is_integer_type(&actual) && is_integer_type(expected) {
|
||||
return;
|
||||
}
|
||||
self.diagnostics.push(
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("type mismatch: expected {expected}, found {actual}"),
|
||||
expr.span(),
|
||||
)
|
||||
.with_help(format!("use 'as {expected}' for explicit conversion")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_type(&self, expr: &Expr) -> Option<NesType> {
|
||||
match expr {
|
||||
Expr::IntLiteral(v, _) => {
|
||||
if *v <= 255 {
|
||||
Some(NesType::U8)
|
||||
} else {
|
||||
Some(NesType::U16)
|
||||
}
|
||||
}
|
||||
Expr::BoolLiteral(_, _) => Some(NesType::Bool),
|
||||
Expr::Ident(name, _) => self.symbols.get(name).map(|s| s.sym_type.clone()),
|
||||
Expr::ButtonRead(_, _, _) => Some(NesType::Bool),
|
||||
Expr::BinaryOp(_, op, _, _) => match op {
|
||||
BinOp::Eq
|
||||
| BinOp::NotEq
|
||||
| BinOp::Lt
|
||||
| BinOp::Gt
|
||||
| BinOp::LtEq
|
||||
| BinOp::GtEq
|
||||
| BinOp::And
|
||||
| BinOp::Or => Some(NesType::Bool),
|
||||
_ => Some(NesType::U8), // Simplified for M1
|
||||
},
|
||||
Expr::UnaryOp(UnaryOp::Not, _, _) => Some(NesType::Bool),
|
||||
Expr::UnaryOp(_, _, _) => Some(NesType::U8),
|
||||
Expr::Call(_, _, _) => Some(NesType::U8), // Simplified for M1
|
||||
Expr::ArrayIndex(name, _, _) => {
|
||||
self.symbols.get(name).and_then(|s| match &s.sym_type {
|
||||
NesType::Array(elem, _) => Some(elem.as_ref().clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_size(t: &NesType) -> u16 {
|
||||
match t {
|
||||
NesType::U8 | NesType::I8 | NesType::Bool => 1,
|
||||
NesType::U16 => 2,
|
||||
NesType::Array(elem, count) => type_size(elem) * count,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_integer_type(t: &NesType) -> bool {
|
||||
matches!(t, NesType::U8 | NesType::I8 | NesType::U16)
|
||||
}
|
||||
128
src/analyzer/tests.rs
Normal file
128
src/analyzer/tests.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use super::*;
|
||||
use crate::errors::ErrorCode;
|
||||
use crate::parser;
|
||||
|
||||
fn analyze_ok(input: &str) -> AnalysisResult {
|
||||
let (prog, diags) = parser::parse(input);
|
||||
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
||||
let prog = prog.unwrap();
|
||||
let result = analyze(&prog);
|
||||
assert!(
|
||||
result.diagnostics.iter().all(|d| !d.is_error()),
|
||||
"analysis errors: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
fn analyze_errors(input: &str) -> Vec<ErrorCode> {
|
||||
let (prog, parse_diags) = parser::parse(input);
|
||||
if prog.is_none() {
|
||||
return parse_diags.into_iter().map(|d| d.code).collect();
|
||||
}
|
||||
let result = analyze(&prog.unwrap());
|
||||
result.diagnostics.into_iter().map(|d| d.code).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_minimal_program() {
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
var px: u8 = 128
|
||||
on frame { px = 1 }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(result.symbols.contains_key("px"));
|
||||
assert_eq!(result.var_allocations.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_allocates_zero_page() {
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
var y: u8 = 0
|
||||
on frame { x = 1 }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
// u8 vars should be allocated in zero page starting at $10
|
||||
assert_eq!(result.var_allocations[0].address, 0x10);
|
||||
assert_eq!(result.var_allocations[1].address, 0x11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_duplicate_var() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
var x: u8 = 1
|
||||
on frame { x = 1 }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(errors.contains(&ErrorCode::E0501));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_undefined_transition() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
state Main {
|
||||
on frame { transition Nonexistent }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(errors.contains(&ErrorCode::E0404));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_valid_transition() {
|
||||
let _result = analyze_ok(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
state Main {
|
||||
on frame { transition Other }
|
||||
}
|
||||
state Other {
|
||||
on frame { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_start_state_exists() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
state Main {
|
||||
on frame { wait_frame }
|
||||
}
|
||||
start Nonexistent
|
||||
"#,
|
||||
);
|
||||
assert!(errors.contains(&ErrorCode::E0404));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_const_symbol() {
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
const SPEED: u8 = 2
|
||||
var px: u8 = 0
|
||||
on frame { px = SPEED }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let sym = result.symbols.get("SPEED").unwrap();
|
||||
assert!(sym.is_const);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue