diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 072dde5..918b35d 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -61,6 +61,7 @@ pub fn analyze(program: &Program) -> AnalysisResult { function_signatures: HashMap::new(), current_return_type: None, in_function_body: false, + struct_layouts: HashMap::new(), }; analyzer.analyze_program(program); @@ -98,10 +99,26 @@ struct Analyzer { /// distinguish "void function" from "state handler" when checking /// `return value` statements. in_function_body: bool, + /// Struct name to layout. Each field has an offset in bytes from + /// the base address of the struct. + struct_layouts: HashMap, +} + +/// Layout info for a struct type. +#[derive(Debug, Clone)] +pub struct StructLayout { + pub size: u16, + pub fields: Vec<(String, NesType, u16)>, // (name, type, offset) } impl Analyzer { fn analyze_program(&mut self, program: &Program) { + // Register struct layouts first so later declarations can + // reference them (for variable sizing, etc.). + for s in &program.structs { + self.register_struct(s); + } + // Register constants for c in &program.constants { self.register_const(c); @@ -279,6 +296,24 @@ impl Analyzer { } self.walk_expr_reads(idx); } + Expr::FieldAccess(name, field, span) => { + // Resolve the struct variable and verify the field + // exists. Mark the synthetic `name.field` variable as + // used so W0103 doesn't fire. + let full_name = format!("{name}.{field}"); + if self.symbols.contains_key(&full_name) { + self.mark_var_used(name); + self.mark_var_used(&full_name); + } else if !self.symbols.contains_key(name) { + self.emit_undefined_var(name, *span); + } else { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!("'{name}' has no field '{field}'"), + *span, + )); + } + } Expr::BinaryOp(lhs, op, rhs, span) => { // W0101: warn about multiply/divide/modulo with a non- // constant operand. These lower to calls into the @@ -416,6 +451,49 @@ impl Analyzer { ); } + /// Register a struct declaration. Computes each field's byte + /// offset from the base address (fields are laid out contiguously + /// in declaration order with no padding), and records the total + /// size. v1 structs only support primitive fields (u8/i8/bool). + fn register_struct(&mut self, s: &StructDecl) { + if self.struct_layouts.contains_key(&s.name) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0501, + format!("duplicate struct declaration of '{}'", s.name), + s.span, + )); + return; + } + let mut fields = Vec::new(); + let mut offset: u16 = 0; + for field in &s.fields { + // Reject non-primitive field types for now. + let size = match &field.field_type { + NesType::U8 | NesType::I8 | NesType::Bool => 1, + _ => { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!( + "struct field '{}' has unsupported type '{}' (only u8/i8/bool allowed)", + field.name, field.field_type + ), + field.span, + )); + continue; + } + }; + fields.push((field.name.clone(), field.field_type.clone(), offset)); + offset += size; + } + self.struct_layouts.insert( + s.name.clone(), + StructLayout { + size: offset, + fields, + }, + ); + } + /// Register each variant of an enum declaration as a `u8` constant /// with a value equal to its declaration order. Variant names must /// be globally unique; a duplicate name emits E0501. @@ -459,7 +537,24 @@ impl Analyzer { return; } - let size = type_size(&var.var_type); + // Validate struct type exists before sizing. + if let NesType::Struct(sname) = &var.var_type { + if !self.struct_layouts.contains_key(sname) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!("unknown struct type '{sname}'"), + var.span, + )); + return; + } + } + + let struct_sizes: HashMap = self + .struct_layouts + .iter() + .map(|(n, l)| (n.clone(), l.size)) + .collect(); + let size = type_size_with(&var.var_type, &struct_sizes); let Some(address) = self.allocate_ram(size, var.span) else { // Allocation failed (E0301 already emitted) — still add the // symbol so that later references don't cascade into E0502, @@ -476,6 +571,43 @@ impl Analyzer { return; }; + // For struct-typed variables, synthesize per-field entries in + // the symbol table and var_allocations. This lets the rest of + // the compiler treat `pos.x` and `pos.y` as ordinary variables + // at known addresses, without special-casing struct layout. + if let NesType::Struct(sname) = &var.var_type { + let layout = self.struct_layouts[sname].clone(); + for (field_name, field_type, offset) in &layout.fields { + let full_name = format!("{}.{field_name}", var.name); + self.symbols.insert( + full_name.clone(), + Symbol { + name: full_name.clone(), + sym_type: field_type.clone(), + is_const: false, + span: var.span, + }, + ); + self.var_allocations.push(VarAllocation { + name: full_name, + address: address + offset, + size: 1, + }); + } + // Also register the struct variable itself (as a symbol + // only — it doesn't have a single VarAllocation entry). + self.symbols.insert( + var.name.clone(), + Symbol { + name: var.name.clone(), + sym_type: var.var_type.clone(), + is_const: false, + span: var.span, + }, + ); + return; + } + self.symbols.insert( var.name.clone(), Symbol { @@ -682,6 +814,23 @@ impl Analyzer { self.mark_var_used(name); self.walk_expr_reads(idx); } + LValue::Field(name, field) => { + let full_name = format!("{name}.{field}"); + if self.symbols.contains_key(&full_name) { + // Assigning to a field is a mutation; don't + // mark the struct variable as "read" just + // because we wrote to one of its fields. + self.mark_var_used(&full_name); + } else if self.symbols.contains_key(name) { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0201, + format!("'{name}' has no field '{field}'"), + *span, + )); + } else { + self.emit_undefined_var(name, *span); + } + } } self.walk_expr_reads(expr); let ltype = self.lvalue_type(lvalue, *span); @@ -830,6 +979,10 @@ impl Analyzer { _ => None, }) } + LValue::Field(name, field) => { + let full_name = format!("{name}.{field}"); + self.symbols.get(&full_name).map(|s| s.sym_type.clone()) + } } } @@ -934,6 +1087,10 @@ impl Analyzer { _ => None, }) } + Expr::FieldAccess(name, field, _) => { + let full_name = format!("{name}.{field}"); + self.symbols.get(&full_name).map(|s| s.sym_type.clone()) + } Expr::ArrayLiteral(_, _) => Some(NesType::U8), // element type inferred from context Expr::Cast(_, target, _) => Some(target.clone()), } @@ -1145,6 +1302,7 @@ fn collect_calls_expr(expr: &Expr, calls: &mut Vec) { Expr::IntLiteral(_, _) | Expr::BoolLiteral(_, _) | Expr::Ident(_, _) + | Expr::FieldAccess(_, _, _) | Expr::ButtonRead(_, _, _) => {} } } @@ -1225,11 +1383,15 @@ fn compute_depth( depth } -fn type_size(t: &NesType) -> u16 { +/// Compute the byte size of a type. Struct types are looked up in +/// `struct_sizes`; if absent, returns 0 (the analyzer will have +/// reported an error already). +fn type_size_with(t: &NesType, struct_sizes: &HashMap) -> u16 { match t { NesType::U8 | NesType::I8 | NesType::Bool => 1, NesType::U16 => 2, - NesType::Array(elem, count) => type_size(elem) * count, + NesType::Array(elem, count) => type_size_with(elem, struct_sizes) * count, + NesType::Struct(name) => struct_sizes.get(name).copied().unwrap_or(0), } } diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 2ae50f8..4b2a40b 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -298,6 +298,68 @@ fn analyze_return_wrong_type() { ); } +#[test] +fn analyze_struct_variable_allocates_fields() { + let result = analyze_ok( + r#" + game "Test" { mapper: NROM } + struct Vec2 { x: u8, y: u8 } + var pos: Vec2 + on frame { + pos.x = 10 + pos.y = pos.x + } + start Main + "#, + ); + // The analyzer should synthesize pos.x and pos.y as separate + // variables with consecutive addresses. + let px = result + .var_allocations + .iter() + .find(|a| a.name == "pos.x") + .expect("pos.x should be allocated"); + let py = result + .var_allocations + .iter() + .find(|a| a.name == "pos.y") + .expect("pos.y should be allocated"); + assert_eq!(py.address, px.address + 1); +} + +#[test] +fn analyze_struct_unknown_field_errors() { + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + struct Vec2 { x: u8, y: u8 } + var pos: Vec2 + on frame { pos.z = 5 } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "unknown field should emit E0201: {errors:?}" + ); +} + +#[test] +fn analyze_unknown_struct_type_errors() { + let errors = analyze_errors( + r#" + game "Test" { mapper: NROM } + var pos: NoSuchStruct + on frame { wait_frame } + start Main + "#, + ); + assert!( + errors.contains(&ErrorCode::E0201), + "unknown struct type should emit E0201: {errors:?}" + ); +} + #[test] fn analyze_enum_variants_as_constants() { let result = analyze_ok( diff --git a/src/assets/resolve.rs b/src/assets/resolve.rs index 58f9815..4cc5ddc 100644 --- a/src/assets/resolve.rs +++ b/src/assets/resolve.rs @@ -84,6 +84,7 @@ mod tests { globals: Vec::new(), constants: Vec::new(), enums: Vec::new(), + structs: Vec::new(), functions: Vec::new(), states: Vec::new(), sprites: vec![sprite], diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index 269c12c..d3e042b 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -427,6 +427,38 @@ impl CodeGen { } } } + LValue::Field(name, field) => { + // Treat `name.field` as a regular variable. The + // analyzer has already synthesized a VarAllocation + // entry under the name `"struct.field"`. + let full_name = format!("{name}.{field}"); + if let Some(&addr) = self.var_addrs.get(&full_name) { + match op { + AssignOp::Assign => { + self.gen_expr(expr); + self.emit_store(addr); + } + AssignOp::PlusAssign => { + self.emit_load(addr); + self.emit(CLC, AM::Implied); + self.gen_adc_expr(expr); + self.emit_store(addr); + } + AssignOp::MinusAssign => { + self.emit_load(addr); + self.emit(SEC, AM::Implied); + self.gen_sbc_expr(expr); + self.emit_store(addr); + } + _ => { + // Other compound ops: read, compute, store + self.emit_load(addr); + self.gen_expr(expr); + self.emit_store(addr); + } + } + } + } LValue::ArrayIndex(name, index) => { if let Some(&base_addr) = self.var_addrs.get(name) { // Evaluate index into X register @@ -774,6 +806,12 @@ impl CodeGen { } } } + Expr::FieldAccess(name, field, _) => { + let full_name = format!("{name}.{field}"); + if let Some(&addr) = self.var_addrs.get(&full_name) { + self.emit_load(addr); + } + } Expr::Call(_, _, _) => { // Function calls as expressions need JSR — handled elsewhere // For now, result is 0 diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 0f7d342..75effe9 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -393,6 +393,38 @@ impl LoweringContext { self.emit(IrOp::ArrayStore(var_id, idx, result)); } } + LValue::Field(name, field) => { + // The analyzer synthesizes a variable named + // `"struct.field"` for each struct field, so we can + // treat field assignment as a regular variable + // assignment to that synthetic name. + let full_name = format!("{name}.{field}"); + let var_id = self.get_or_create_var(&full_name); + match op { + AssignOp::Assign => { + let val = self.lower_expr(expr); + self.emit(IrOp::StoreVar(var_id, val)); + } + _ => { + let current = self.fresh_temp(); + self.emit(IrOp::LoadVar(current, var_id)); + let rhs = self.lower_expr(expr); + let result = self.fresh_temp(); + let ir_op = match op { + AssignOp::PlusAssign => IrOp::Add(result, current, rhs), + AssignOp::MinusAssign => IrOp::Sub(result, current, rhs), + AssignOp::AmpAssign => IrOp::And(result, current, rhs), + AssignOp::PipeAssign => IrOp::Or(result, current, rhs), + AssignOp::CaretAssign => IrOp::Xor(result, current, rhs), + AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1), + AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1), + AssignOp::Assign => unreachable!(), + }; + self.emit(ir_op); + self.emit(IrOp::StoreVar(var_id, result)); + } + } + } } } @@ -537,6 +569,16 @@ impl LoweringContext { self.emit(IrOp::ArrayLoad(t, var_id, idx)); t } + Expr::FieldAccess(name, field, _) => { + // Field access lowers to a plain load of the + // synthetic `"struct.field"` variable produced by the + // analyzer. + let full_name = format!("{name}.{field}"); + let var_id = self.get_or_create_var(&full_name); + let t = self.fresh_temp(); + self.emit(IrOp::LoadVar(t, var_id)); + t + } Expr::BinaryOp(left, op, right, _) => self.lower_binop(left, *op, right), Expr::UnaryOp(op, inner, _) => { let val = self.lower_expr(inner); @@ -697,6 +739,10 @@ fn type_size(t: &NesType) -> u16 { NesType::U8 | NesType::I8 | NesType::Bool => 1, NesType::U16 => 2, NesType::Array(elem, count) => type_size(elem) * count, + // Struct sizes are resolved in the analyzer. IR lowering only + // sees struct types on `var` declarations, which are skipped + // below via the analyzer's synthetic field allocations. + NesType::Struct(_) => 0, } } diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 00e871e..9ba6437 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -430,6 +430,7 @@ impl<'a> Lexer<'a> { "var" => TokenKind::KwVar, "const" => TokenKind::KwConst, "enum" => TokenKind::KwEnum, + "struct" => TokenKind::KwStruct, "if" => TokenKind::KwIf, "else" => TokenKind::KwElse, "while" => TokenKind::KwWhile, diff --git a/src/lexer/tests.rs b/src/lexer/tests.rs index b10f775..9f1d3b2 100644 --- a/src/lexer/tests.rs +++ b/src/lexer/tests.rs @@ -123,6 +123,7 @@ fn lex_all_keywords() { ("var", TokenKind::KwVar), ("const", TokenKind::KwConst), ("enum", TokenKind::KwEnum), + ("struct", TokenKind::KwStruct), ("if", TokenKind::KwIf), ("else", TokenKind::KwElse), ("while", TokenKind::KwWhile), diff --git a/src/lexer/token.rs b/src/lexer/token.rs index eb75b35..96d8838 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -46,6 +46,7 @@ pub enum TokenKind { KwVar, KwConst, KwEnum, + KwStruct, KwIf, KwElse, KwWhile, @@ -149,6 +150,7 @@ impl std::fmt::Display for TokenKind { Self::KwVar => write!(f, "var"), Self::KwConst => write!(f, "const"), Self::KwEnum => write!(f, "enum"), + Self::KwStruct => write!(f, "struct"), Self::KwIf => write!(f, "if"), Self::KwElse => write!(f, "else"), Self::KwWhile => write!(f, "while"), diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 62ad6cd..321a799 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -6,6 +6,7 @@ pub struct Program { pub globals: Vec, pub constants: Vec, pub enums: Vec, + pub structs: Vec, pub functions: Vec, pub states: Vec, pub sprites: Vec, @@ -27,6 +28,24 @@ pub struct EnumDecl { pub span: Span, } +/// `struct Name { field1: u8, field2: u8 }` — composite type with a +/// known layout. Fields are stored contiguously in memory in +/// declaration order (no padding). Only primitive-sized fields (u8, +/// i8, bool) are supported in the v1 layout. +#[derive(Debug, Clone)] +pub struct StructDecl { + pub name: String, + pub fields: Vec, + pub span: Span, +} + +#[derive(Debug, Clone)] +pub struct StructField { + pub name: String, + pub field_type: NesType, + pub span: Span, +} + #[derive(Debug, Clone)] pub struct SpriteDecl { pub name: String, @@ -148,6 +167,9 @@ pub enum NesType { U16, Bool, Array(Box, u16), + /// A user-declared struct, identified by its name. The analyzer + /// looks up field layouts in the `StructDecl` table. + Struct(String), } impl std::fmt::Display for NesType { @@ -158,6 +180,7 @@ impl std::fmt::Display for NesType { Self::U16 => write!(f, "u16"), Self::Bool => write!(f, "bool"), Self::Array(t, n) => write!(f, "{t}[{n}]"), + Self::Struct(name) => write!(f, "{name}"), } } } @@ -174,6 +197,8 @@ pub enum Expr { BoolLiteral(bool, Span), Ident(String, Span), ArrayIndex(String, Box, Span), + /// Field access on a struct variable: `pos.x`. + FieldAccess(String, String, Span), BinaryOp(Box, BinOp, Box, Span), UnaryOp(UnaryOp, Box, Span), Call(String, Vec, Span), @@ -189,6 +214,7 @@ impl Expr { | Self::BoolLiteral(_, s) | Self::Ident(_, s) | Self::ArrayIndex(_, _, s) + | Self::FieldAccess(_, _, s) | Self::BinaryOp(_, _, _, s) | Self::UnaryOp(_, _, s) | Self::Call(_, _, s) @@ -300,6 +326,9 @@ pub struct DrawStmt { pub enum LValue { Var(String), ArrayIndex(String, Box), + /// Struct field: `pos.x = 5`. First string is the struct variable + /// name, second is the field name. + Field(String, String), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 06a89cc..d2b99da 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -117,6 +117,7 @@ impl Parser { let mut globals = Vec::new(); let mut constants = Vec::new(); let mut enums: Vec = Vec::new(); + let mut structs: Vec = Vec::new(); let mut functions = Vec::new(); let mut states = Vec::new(); let mut sprites = Vec::new(); @@ -147,6 +148,9 @@ impl Parser { TokenKind::KwEnum => { enums.push(self.parse_enum_decl()?); } + TokenKind::KwStruct => { + structs.push(self.parse_struct_decl()?); + } TokenKind::KwState => { states.push(self.parse_state_decl()?); } @@ -221,6 +225,7 @@ impl Parser { globals, constants, enums, + structs, functions, states, sprites, @@ -232,6 +237,40 @@ impl Parser { }) } + fn parse_struct_decl(&mut self) -> Result { + let start = self.current_span(); + self.expect(&TokenKind::KwStruct)?; + let (name, _) = self.expect_ident()?; + self.expect(&TokenKind::LBrace)?; + let mut fields = Vec::new(); + while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof { + let field_span = self.current_span(); + let (field_name, _) = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + let field_type = self.parse_type()?; + fields.push(StructField { + name: field_name, + field_type, + span: field_span, + }); + if *self.peek() == TokenKind::Comma { + self.advance(); + } else if *self.peek() != TokenKind::RBrace { + return Err(Diagnostic::error( + ErrorCode::E0201, + "expected ',' or '}' in struct body", + self.current_span(), + )); + } + } + self.expect(&TokenKind::RBrace)?; + Ok(StructDecl { + name, + fields, + span: Span::new(start.file_id, start.start, self.current_span().end), + }) + } + fn parse_enum_decl(&mut self) -> Result { let start = self.current_span(); self.expect(&TokenKind::KwEnum)?; @@ -1121,6 +1160,19 @@ impl Parser { start, )) } + TokenKind::Dot => { + // Field assignment: name.field = value + self.advance(); + let (field, _) = self.expect_ident()?; + let op = self.parse_assign_op()?; + let value = self.parse_expr()?; + Ok(Statement::Assign( + LValue::Field(name, field), + op, + value, + start, + )) + } TokenKind::LParen => { // Function call self.advance(); @@ -1207,6 +1259,12 @@ impl Parser { self.advance(); NesType::Bool } + TokenKind::Ident(name) => { + // User-declared struct types are referenced by name. + // The analyzer validates that the name exists. + self.advance(); + NesType::Struct(name) + } _ => { return Err(Diagnostic::error( ErrorCode::E0201, @@ -1464,6 +1522,13 @@ impl Parser { return Ok(Expr::Call(name, args, span)); } + // Check for field access: `name.field` + if *self.peek() == TokenKind::Dot { + self.advance(); + let (field, _) = self.expect_ident()?; + return Ok(Expr::FieldAccess(name, field, span)); + } + Ok(Expr::Ident(name, span)) } TokenKind::LBracket => { diff --git a/src/parser/tests.rs b/src/parser/tests.rs index 3e301e7..1ba7eac 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -665,6 +665,37 @@ fn parse_mmc3_mapper() { assert_eq!(prog.game.mapper, Mapper::MMC3); } +#[test] +fn parse_struct_decl() { + let src = r#" + game "Test" { mapper: NROM } + struct Vec2 { x: u8, y: u8 } + on frame { wait_frame } + start Main + "#; + let prog = parse_ok(src); + assert_eq!(prog.structs.len(), 1); + assert_eq!(prog.structs[0].name, "Vec2"); + assert_eq!(prog.structs[0].fields.len(), 2); + assert_eq!(prog.structs[0].fields[0].name, "x"); + assert_eq!(prog.structs[0].fields[1].name, "y"); +} + +#[test] +fn parse_struct_field_access_expr() { + let src = r#" + game "Test" { mapper: NROM } + struct Vec2 { x: u8, y: u8 } + var pos: Vec2 + on frame { + pos.x = 10 + pos.y = pos.x + } + start Main + "#; + parse_ok(src); +} + #[test] fn parse_enum_decl() { let src = r#" diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 525b5de..612c272 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -141,6 +141,29 @@ fn program_with_functions() { rom::validate_ines(&rom_data).expect("should be valid iNES"); } +#[test] +fn program_with_structs() { + let source = r#" + game "Structs" { mapper: NROM } + struct Vec2 { x: u8, y: u8 } + struct Player { health: u8, lives: u8 } + + var pos: Vec2 + var hero: Player + + on frame { + pos.x = 100 + pos.y = 50 + hero.health = 3 + hero.lives = 5 + if button.right { pos.x += 1 } + } + start Main + "#; + let rom_data = compile(source); + rom::validate_ines(&rom_data).expect("should be valid iNES"); +} + #[test] fn program_with_enums() { let source = r#"