mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 09:18:01 +00:00
Re-adds `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` as first-class
declarations, plus `set_palette Name` and `load_background Name`
statements for runtime swaps. Unlike the previous iteration that
quietly no-op'd, this one is fully wired through the pipeline and
its behavior is pinned by both unit tests and an emulator golden.
Pipeline:
- Lexer: re-adds `palette`, `background`, `set_palette`,
`load_background` keywords and tokenizes them.
- AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl`
(name + 0..=960 tile bytes + 0..=64 attribute bytes) live in
`Program`. `Statement::SetPalette` and `Statement::LoadBackground`
name-reference these declarations.
- Parser: `palette Name { colors: [...] }` / `background Name
{ tiles: [...], attributes: [...] }` blocks and their statement
forms parse via the existing byte-array helper.
- Analyzer: validates colour indices ($00-$3F), palette length
(<=32), nametable length (<=960), attribute length (<=64), and
duplicate decl names. `set_palette` / `load_background` targets
must reference a declared name (E0502 otherwise). When a program
declares palette or background, the analyzer bumps the user
zero-page allocator's starting address from `$10` to `$18` to
reserve `$11-$17` for the runtime update handshake — programs
that don't use the feature keep the old layout so their emulator
goldens stay byte-exact.
- Assets: `PaletteData` and `BackgroundData` resolve declarations
into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and
expose `label()` / `tiles_label()` / `attrs_label()` for codegen
to reference.
- IR: new `IrOp::SetPalette(String)` and
`IrOp::LoadBackground(String)`; lowering forwards the names
verbatim.
- Codegen: `gen_set_palette` writes the palette label pointer into
ZP `$12/$13` and ORs bit 0 into the update flags at `$11`;
`gen_load_background` does the same for tile/attribute pointers
at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used`
marker so the linker splices in the NMI apply helper only when
the feature is actually used.
- Runtime: `gen_initial_palette_load` and
`gen_initial_background_load` write the first declared
palette/background at reset time (before rendering is enabled,
where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a
new flag; when true it splices `gen_ppu_update_apply` at the top
of the NMI body, which checks the `$11` flags byte and copies
pending palette / nametable data to `$3F00` / `$2000` inside
vblank. All helpers use only ZP $02/$03 as scratch at reset time
and never clobber ZP slots live across NMI.
- Linker: new `link_banked_with_ppu` takes slice of `PaletteData` /
`BackgroundData`; splices each blob as a labelled data block in
PRG ROM, picks the first-declared as the reset-time load target,
enables background rendering automatically when a background is
declared, and threads `has_ppu_updates` into `gen_nmi`. Old
`link_banked` remains as a thin wrapper for callers without
palette/background data so existing tests don't shift.
Tests:
- Lexer: tokenization of the 4 new keywords (single added test case).
- Parser: 5 new tests for `palette` / `background` decls with and
without attributes, plus `set_palette` / `load_background`
statements.
- Analyzer: 9 new tests covering acceptance of declared
palettes/backgrounds, E0502 for unknown names, E0201 for
out-of-range NES colors and oversized blobs, E0501 for duplicate
names, and the zero-page-layout guard (palette/bg decls bump ZP
start; no decls keeps it at $10).
- Resolver: 3 new tests for zero-padding, truncation of oversized
decls, and label derivation.
- IR: 2 new lowering tests for `set_palette` and `load_background`.
- Integration: 5 new tests — blob contents spliced verbatim into
PRG, `STA $12` / `STA $14` emitted by set_palette /
load_background codegen, and a regression guard that programs
without palette/background still land user vars at $10.
- Emulator: new `examples/palette_and_background.ne` driven by a
frame counter that toggles between `CoolBlues` / `WarmReds` and
`TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio
hash checked in under `tests/emulator/goldens/` and verified via
`node run_examples.mjs` — rendered image shows the blue
`CoolBlues` palette with the nametable populated from
`TitleScreen`.
Docs:
- `README.md` adds the feature to the headline list and the example
table.
- `docs/language-guide.md` restores the palette/background sections
with the full 32-byte layout table and `set_palette` /
`load_background` statement references.
- `docs/future-work.md` replaces the "removed as dead code" entry
with the remaining gaps (PNG-sourced palette and nametable
assets, cross-vblank large background updates, memory-map
reporting).
- `spec.md` restores the grammar productions and usage examples.
- `examples/README.md` lists the new demo.
All 497 unit + integration tests pass. Clippy clean. All 21
emulator goldens match after the update pass.
https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
1714 lines
65 KiB
Rust
1714 lines
65 KiB
Rust
#[cfg(test)]
|
||
mod tests;
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
use crate::errors::{Diagnostic, ErrorCode, Label, Level};
|
||
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>,
|
||
pub call_graph: HashMap<String, Vec<String>>,
|
||
pub max_depths: HashMap<String, u32>,
|
||
}
|
||
|
||
/// Default call stack depth limit for the NES runtime.
|
||
const DEFAULT_STACK_DEPTH: u32 = 8;
|
||
|
||
/// Upper bound (exclusive) for user-variable zero-page allocation.
|
||
/// Addresses `$80-$FF` are reserved for IR codegen temp slots, so user
|
||
/// globals must fit into `$10-$7F`.
|
||
const ZP_USER_CAP: u8 = 0x80;
|
||
|
||
/// Exclusive upper bound of usable RAM. The NES has 2 KB of internal
|
||
/// RAM at `$0000-$07FF`; the allocator uses up through `$07FF`.
|
||
const RAM_END: u16 = 0x0800;
|
||
|
||
/// Analyze a parsed program for semantic errors.
|
||
pub fn analyze(program: &Program) -> AnalysisResult {
|
||
// Pre-collect declared and builtin-matchable sfx/music names so
|
||
// the statement walker can check `play` / `start_music` targets.
|
||
let mut sfx_names = HashSet::new();
|
||
for decl in &program.sfx {
|
||
sfx_names.insert(decl.name.clone());
|
||
}
|
||
let mut music_names = HashSet::new();
|
||
for decl in &program.music {
|
||
music_names.insert(decl.name.clone());
|
||
}
|
||
let mut palette_names = HashSet::new();
|
||
for decl in &program.palettes {
|
||
palette_names.insert(decl.name.clone());
|
||
}
|
||
let mut background_names = HashSet::new();
|
||
for decl in &program.backgrounds {
|
||
background_names.insert(decl.name.clone());
|
||
}
|
||
// Programs that use palette or background declarations need 7
|
||
// bytes of zero page for the vblank-safe update handshake
|
||
// (`$11` flags + 2 × 3 pointer slots). Bump the user zero-page
|
||
// start past that region so var allocation doesn't collide with
|
||
// the runtime slots.
|
||
let needs_ppu_update_slots = !program.palettes.is_empty() || !program.backgrounds.is_empty();
|
||
let next_zp_addr = if needs_ppu_update_slots { 0x18 } else { 0x10 };
|
||
let mut analyzer = Analyzer {
|
||
symbols: HashMap::new(),
|
||
var_allocations: Vec::new(),
|
||
diagnostics: Vec::new(),
|
||
sfx_names,
|
||
music_names,
|
||
palette_names,
|
||
background_names,
|
||
next_ram_addr: 0x0300, // $0300 is first usable RAM after OAM buffer
|
||
next_zp_addr,
|
||
call_graph: HashMap::new(),
|
||
max_depths: HashMap::new(),
|
||
stack_depth_limit: DEFAULT_STACK_DEPTH,
|
||
in_loop: false,
|
||
used_vars: HashSet::new(),
|
||
function_signatures: HashMap::new(),
|
||
current_return_type: None,
|
||
in_function_body: false,
|
||
struct_layouts: HashMap::new(),
|
||
};
|
||
analyzer.analyze_program(program);
|
||
|
||
AnalysisResult {
|
||
symbols: analyzer.symbols,
|
||
var_allocations: analyzer.var_allocations,
|
||
diagnostics: analyzer.diagnostics,
|
||
call_graph: analyzer.call_graph,
|
||
max_depths: analyzer.max_depths,
|
||
}
|
||
}
|
||
|
||
struct Analyzer {
|
||
symbols: HashMap<String, Symbol>,
|
||
var_allocations: Vec<VarAllocation>,
|
||
diagnostics: Vec<Diagnostic>,
|
||
/// Set of sfx names declared in the program (user-provided
|
||
/// `sfx Name { ... }` blocks). Used to validate `play Name`
|
||
/// targets. If a name isn't in here and isn't a known builtin,
|
||
/// the analyzer emits E0505.
|
||
sfx_names: HashSet<String>,
|
||
/// Set of music names declared in the program.
|
||
music_names: HashSet<String>,
|
||
/// Set of palette names declared in the program. Used to
|
||
/// validate `set_palette Name` targets.
|
||
palette_names: HashSet<String>,
|
||
/// Set of background names declared in the program. Used to
|
||
/// validate `load_background Name` targets.
|
||
background_names: HashSet<String>,
|
||
next_ram_addr: u16,
|
||
next_zp_addr: u8,
|
||
call_graph: HashMap<String, Vec<String>>,
|
||
max_depths: HashMap<String, u32>,
|
||
stack_depth_limit: u32,
|
||
in_loop: bool,
|
||
/// Names of variables that have been read somewhere in the program.
|
||
/// Used for the W0103 unused-variable warning.
|
||
used_vars: HashSet<String>,
|
||
/// Function name to parameter types (in order). Used to validate
|
||
/// call arity and argument types.
|
||
function_signatures: HashMap<String, Vec<NesType>>,
|
||
/// Return type of the function currently being analyzed, or None
|
||
/// when the function has no declared return type. Only meaningful
|
||
/// when `in_function_body` is true.
|
||
current_return_type: Option<NesType>,
|
||
/// True while analyzing a function body (as opposed to a state
|
||
/// handler's `on_enter` / `on_exit` / `on_frame` block). Used to
|
||
/// 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<String, StructLayout>,
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
// Register enum variants as constants with values 0, 1, 2, ...
|
||
for e in &program.enums {
|
||
self.register_enum(e);
|
||
}
|
||
|
||
// Register and allocate globals
|
||
for var in &program.globals {
|
||
self.register_var(var);
|
||
}
|
||
|
||
// Validate palette and background declarations. Palettes
|
||
// must be ≤ 32 bytes (PPU palette RAM is $3F00-$3F1F) and
|
||
// each byte must fit in 6 bits (NES master palette is
|
||
// `$00-$3F`). Backgrounds must fit in a single 32×30
|
||
// nametable: ≤ 960 tile bytes, ≤ 64 attribute bytes.
|
||
// Duplicate names are caught via the shared symbol table.
|
||
let mut seen_palettes = HashSet::new();
|
||
for palette in &program.palettes {
|
||
if !seen_palettes.insert(palette.name.clone()) {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0501,
|
||
format!("duplicate palette '{}'", palette.name),
|
||
palette.span,
|
||
));
|
||
}
|
||
if palette.colors.len() > 32 {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0201,
|
||
format!(
|
||
"palette '{}' has {} colors; maximum is 32",
|
||
palette.name,
|
||
palette.colors.len()
|
||
),
|
||
palette.span,
|
||
));
|
||
}
|
||
for (i, c) in palette.colors.iter().enumerate() {
|
||
if *c > 0x3F {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0201,
|
||
format!(
|
||
"palette '{}' color {i} is ${c:02X}; NES master palette indices are $00-$3F",
|
||
palette.name,
|
||
),
|
||
palette.span,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let mut seen_backgrounds = HashSet::new();
|
||
for bg in &program.backgrounds {
|
||
if !seen_backgrounds.insert(bg.name.clone()) {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0501,
|
||
format!("duplicate background '{}'", bg.name),
|
||
bg.span,
|
||
));
|
||
}
|
||
if bg.tiles.len() > 960 {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0201,
|
||
format!(
|
||
"background '{}' has {} tile bytes; maximum is 960 (32×30)",
|
||
bg.name,
|
||
bg.tiles.len()
|
||
),
|
||
bg.span,
|
||
));
|
||
}
|
||
if bg.attributes.len() > 64 {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0201,
|
||
format!(
|
||
"background '{}' has {} attribute bytes; maximum is 64 (8×8)",
|
||
bg.name,
|
||
bg.attributes.len()
|
||
),
|
||
bg.span,
|
||
));
|
||
}
|
||
}
|
||
|
||
// Register functions as symbols
|
||
for fun in &program.functions {
|
||
self.register_fun(fun);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
// `on scanline(N)` is only valid with mappers that have a
|
||
// scanline-counting IRQ source (currently only MMC3).
|
||
if !state.on_scanline.is_empty() && program.game.mapper != Mapper::MMC3 {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
"`on scanline` requires the MMC3 mapper",
|
||
state.span,
|
||
));
|
||
}
|
||
for (_, block) in &state.on_scanline {
|
||
self.check_block(block, &state_names);
|
||
}
|
||
}
|
||
|
||
// Type-check function bodies. Parameters are registered as
|
||
// symbols for the duration of the body check so that identifier
|
||
// references (and the W0103 used-variable tracker) can resolve
|
||
// them. They are unregistered afterwards to avoid leaking into
|
||
// the global scope. Parameters are also pre-marked as "used" so
|
||
// we do not emit W0103 for unused function arguments (which are
|
||
// a common and deliberate pattern).
|
||
for fun in &program.functions {
|
||
let mut added_params = Vec::new();
|
||
for param in &fun.params {
|
||
if !self.symbols.contains_key(¶m.name) {
|
||
self.symbols.insert(
|
||
param.name.clone(),
|
||
Symbol {
|
||
name: param.name.clone(),
|
||
sym_type: param.param_type.clone(),
|
||
is_const: false,
|
||
span: fun.span,
|
||
},
|
||
);
|
||
added_params.push(param.name.clone());
|
||
}
|
||
self.mark_var_used(¶m.name);
|
||
}
|
||
self.current_return_type.clone_from(&fun.return_type);
|
||
self.in_function_body = true;
|
||
self.check_block(&fun.body, &state_names);
|
||
self.current_return_type = None;
|
||
self.in_function_body = false;
|
||
for name in &added_params {
|
||
self.symbols.remove(name);
|
||
}
|
||
}
|
||
|
||
// Build call graph
|
||
self.build_call_graph(program);
|
||
|
||
// Detect recursion
|
||
let recursive_fns = detect_recursion(&self.call_graph);
|
||
for name in &recursive_fns {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0402,
|
||
format!("recursion detected in function '{name}'"),
|
||
program.span,
|
||
));
|
||
}
|
||
|
||
// Compute max call depths from entry points (state handlers)
|
||
self.compute_max_depths(program);
|
||
|
||
// Check for unused variables (W0103). Variables whose names
|
||
// start with '_' are exempt by convention. Both globals and
|
||
// state-local variables are checked.
|
||
for var in &program.globals {
|
||
self.check_unused_var(var);
|
||
}
|
||
for state in &program.states {
|
||
for var in &state.locals {
|
||
self.check_unused_var(var);
|
||
}
|
||
}
|
||
|
||
// Check for unreachable states (W0104).
|
||
self.check_unreachable_states(program);
|
||
}
|
||
|
||
/// Mark a variable name as having been read somewhere in the program.
|
||
fn mark_var_used(&mut self, name: &str) {
|
||
self.used_vars.insert(name.to_string());
|
||
}
|
||
|
||
/// Emit W0103 if `var` is never read anywhere. Variables named
|
||
/// with a leading `_` are exempt by convention.
|
||
fn check_unused_var(&mut self, var: &VarDecl) {
|
||
if var.name.starts_with('_') {
|
||
return;
|
||
}
|
||
if self.used_vars.contains(&var.name) {
|
||
return;
|
||
}
|
||
self.diagnostics.push(Diagnostic {
|
||
level: Level::Warning,
|
||
code: ErrorCode::W0103,
|
||
message: format!("unused variable '{}'", var.name),
|
||
span: var.span,
|
||
labels: Vec::<Label>::new(),
|
||
help: Some("prefix with '_' to silence this warning, or remove the declaration".into()),
|
||
note: None,
|
||
});
|
||
}
|
||
|
||
/// Recursively walk an expression tree and mark every identifier that
|
||
/// appears as an `Expr::Ident` (or as an `Expr::ArrayIndex` base) as
|
||
/// "read". Used by the W0103 unused-variable analysis. Also emits
|
||
/// E0502 for any identifier that is not defined in the symbol table.
|
||
fn walk_expr_reads(&mut self, expr: &Expr) {
|
||
match expr {
|
||
Expr::Ident(name, span) => {
|
||
if self.symbols.contains_key(name) {
|
||
self.mark_var_used(name);
|
||
} else {
|
||
self.emit_undefined_var(name, *span);
|
||
}
|
||
}
|
||
Expr::ArrayIndex(name, idx, span) => {
|
||
// Array base is a read; index may contain more reads.
|
||
if self.symbols.contains_key(name) {
|
||
self.mark_var_used(name);
|
||
} else {
|
||
self.emit_undefined_var(name, *span);
|
||
}
|
||
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
|
||
// software multiply/divide routines, which are far more
|
||
// expensive than the simple inline opcodes used for
|
||
// add/sub. A literal like `x * 2` can be strength-
|
||
// reduced to a shift and is therefore cheap.
|
||
if matches!(op, BinOp::Mul | BinOp::Div | BinOp::Mod)
|
||
&& !is_small_constant(lhs)
|
||
&& !is_small_constant(rhs)
|
||
{
|
||
let op_name = match op {
|
||
BinOp::Mul => "multiply",
|
||
BinOp::Div => "divide",
|
||
BinOp::Mod => "modulo",
|
||
_ => unreachable!(),
|
||
};
|
||
self.diagnostics.push(
|
||
Diagnostic::warning(
|
||
ErrorCode::W0101,
|
||
format!("{op_name} with two non-constant operands is expensive"),
|
||
*span,
|
||
)
|
||
.with_help(
|
||
"consider precomputing or using a power-of-2 constant for strength reduction",
|
||
),
|
||
);
|
||
}
|
||
self.walk_expr_reads(lhs);
|
||
self.walk_expr_reads(rhs);
|
||
}
|
||
Expr::UnaryOp(_, inner, _) | Expr::Cast(inner, _, _) => {
|
||
self.walk_expr_reads(inner);
|
||
}
|
||
Expr::Call(name, args, span) => {
|
||
// If the function is known, validate its call signature.
|
||
// Undefined-function errors are surfaced elsewhere (for
|
||
// Statement::Call) and via the call-graph pass.
|
||
if self.function_signatures.contains_key(name) {
|
||
self.check_call_signature(name, args, *span);
|
||
}
|
||
for arg in args {
|
||
self.walk_expr_reads(arg);
|
||
}
|
||
}
|
||
Expr::ArrayLiteral(elems, _) => {
|
||
for e in elems {
|
||
self.walk_expr_reads(e);
|
||
}
|
||
}
|
||
Expr::StructLiteral(name, fields, span) => {
|
||
// Validate that the struct type exists and that each
|
||
// named field is actually declared. Missing or extra
|
||
// fields are an error; duplicate fields are silently
|
||
// ignored (last-writer-wins).
|
||
if let Some(layout) = self.struct_layouts.get(name).cloned() {
|
||
for (fname, fexpr) in fields {
|
||
if let Some((_, field_type, _)) =
|
||
layout.fields.iter().find(|(n, _, _)| n == fname)
|
||
{
|
||
self.walk_expr_reads(fexpr);
|
||
self.check_expr_type(fexpr, field_type);
|
||
} else {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0201,
|
||
format!("struct '{name}' has no field '{fname}'"),
|
||
*span,
|
||
));
|
||
}
|
||
}
|
||
} else {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0201,
|
||
format!("unknown struct type '{name}'"),
|
||
*span,
|
||
));
|
||
}
|
||
}
|
||
Expr::IntLiteral(_, _) | Expr::BoolLiteral(_, _) | Expr::ButtonRead(_, _, _) => {}
|
||
}
|
||
}
|
||
|
||
/// Suggest a similarly-named symbol for undefined-variable errors.
|
||
/// Uses a simple heuristic: same first character and similar length.
|
||
fn suggest_var_name(&self, unknown: &str) -> Option<String> {
|
||
let first = unknown.chars().next()?;
|
||
self.symbols
|
||
.keys()
|
||
.filter(|name| {
|
||
name.starts_with(first)
|
||
&& name.len().abs_diff(unknown.len()) <= 2
|
||
&& name.as_str() != unknown
|
||
})
|
||
.min_by_key(|name| name.len().abs_diff(unknown.len()))
|
||
.cloned()
|
||
}
|
||
|
||
/// Emit E0502 for an undefined variable reference, with a "did you mean"
|
||
/// suggestion if a similar symbol exists.
|
||
fn emit_undefined_var(&mut self, name: &str, span: Span) {
|
||
let mut diag = Diagnostic::error(
|
||
ErrorCode::E0502,
|
||
format!("undefined variable '{name}'"),
|
||
span,
|
||
);
|
||
if let Some(suggestion) = self.suggest_var_name(name) {
|
||
diag = diag.with_help(format!("did you mean '{suggestion}'?"));
|
||
}
|
||
self.diagnostics.push(diag);
|
||
}
|
||
|
||
/// Reachability analysis for states. Performs a BFS from the start state
|
||
/// through every transition in state handlers and emits W0104 for any
|
||
/// state that is never reached.
|
||
fn check_unreachable_states(&mut self, program: &Program) {
|
||
let mut reachable: HashSet<String> = HashSet::new();
|
||
let mut queue: Vec<String> = vec![program.start_state.clone()];
|
||
|
||
while let Some(state_name) = queue.pop() {
|
||
if !reachable.insert(state_name.clone()) {
|
||
continue;
|
||
}
|
||
if let Some(state) = program.states.iter().find(|s| s.name == state_name) {
|
||
collect_transitions_from_state(state, &mut queue);
|
||
}
|
||
}
|
||
|
||
for state in &program.states {
|
||
if !reachable.contains(&state.name) {
|
||
self.diagnostics.push(Diagnostic {
|
||
level: Level::Warning,
|
||
code: ErrorCode::W0104,
|
||
message: format!("state '{}' is unreachable from start state", state.name),
|
||
span: state.span,
|
||
labels: Vec::<Label>::new(),
|
||
help: Some(
|
||
"add a 'transition' to this state from a reachable state, or remove it"
|
||
.into(),
|
||
),
|
||
note: None,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 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.
|
||
fn register_enum(&mut self, e: &EnumDecl) {
|
||
if self.symbols.contains_key(&e.name) {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0501,
|
||
format!("duplicate declaration of '{}'", e.name),
|
||
e.span,
|
||
));
|
||
// Don't return — still register the variants.
|
||
}
|
||
for (variant_name, variant_span) in &e.variants {
|
||
if self.symbols.contains_key(variant_name) {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0501,
|
||
format!("duplicate declaration of '{variant_name}'"),
|
||
*variant_span,
|
||
));
|
||
continue;
|
||
}
|
||
self.symbols.insert(
|
||
variant_name.clone(),
|
||
Symbol {
|
||
name: variant_name.clone(),
|
||
sym_type: NesType::U8,
|
||
is_const: true,
|
||
span: *variant_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;
|
||
}
|
||
|
||
// 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<String, u16> = 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,
|
||
// but don't record a var_allocations 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;
|
||
};
|
||
|
||
// 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 {
|
||
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 register_fun(&mut self, fun: &FunDecl) {
|
||
if self.symbols.contains_key(&fun.name) {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0501,
|
||
format!("duplicate declaration of '{}'", fun.name),
|
||
fun.span,
|
||
));
|
||
return;
|
||
}
|
||
let sym_type = fun.return_type.clone().unwrap_or(NesType::U8);
|
||
self.symbols.insert(
|
||
fun.name.clone(),
|
||
Symbol {
|
||
name: fun.name.clone(),
|
||
sym_type,
|
||
is_const: false,
|
||
span: fun.span,
|
||
},
|
||
);
|
||
let param_types: Vec<NesType> = fun.params.iter().map(|p| p.param_type.clone()).collect();
|
||
self.function_signatures
|
||
.insert(fun.name.clone(), param_types);
|
||
}
|
||
|
||
/// Attempt to allocate `size` bytes of RAM for a variable declared
|
||
/// at `span`. Returns `None` on overflow, emitting E0301. The
|
||
/// zero-page user region is bounded above by [`ZP_USER_CAP`] to
|
||
/// leave room for IR codegen temp slots starting at $80.
|
||
fn allocate_ram(&mut self, size: u16, span: Span) -> Option<u16> {
|
||
// Zero-page u8 allocation — bounded by ZP_USER_CAP to avoid
|
||
// colliding with the IR temp region at $80+.
|
||
if size == 1 && self.next_zp_addr < ZP_USER_CAP {
|
||
let addr = u16::from(self.next_zp_addr);
|
||
self.next_zp_addr = self.next_zp_addr.wrapping_add(1);
|
||
return Some(addr);
|
||
}
|
||
|
||
// Larger / remaining allocations go into the main RAM region
|
||
// after the OAM buffer.
|
||
let end = self.next_ram_addr.checked_add(size)?;
|
||
if end > RAM_END {
|
||
self.diagnostics.push(
|
||
Diagnostic::error(
|
||
ErrorCode::E0301,
|
||
"out of RAM: too many variables declared",
|
||
span,
|
||
)
|
||
.with_help(
|
||
"the NES only has 2 KB of RAM ($0000-$07FF); consider removing some globals",
|
||
),
|
||
);
|
||
return None;
|
||
}
|
||
let addr = self.next_ram_addr;
|
||
self.next_ram_addr = end;
|
||
Some(addr)
|
||
}
|
||
|
||
fn build_call_graph(&mut self, program: &Program) {
|
||
// Record calls from each function body
|
||
for fun in &program.functions {
|
||
let callees = collect_calls(&fun.body);
|
||
self.call_graph.insert(fun.name.clone(), callees);
|
||
}
|
||
|
||
// Record calls from each state handler
|
||
for state in &program.states {
|
||
if let Some(block) = &state.on_enter {
|
||
let key = format!("{}::enter", state.name);
|
||
let callees = collect_calls(block);
|
||
self.call_graph.insert(key, callees);
|
||
}
|
||
if let Some(block) = &state.on_exit {
|
||
let key = format!("{}::exit", state.name);
|
||
let callees = collect_calls(block);
|
||
self.call_graph.insert(key, callees);
|
||
}
|
||
if let Some(block) = &state.on_frame {
|
||
let key = format!("{}::frame", state.name);
|
||
let callees = collect_calls(block);
|
||
self.call_graph.insert(key, callees);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn compute_max_depths(&mut self, program: &Program) {
|
||
let mut cache = HashMap::new();
|
||
|
||
// Entry points are state handlers
|
||
for state in &program.states {
|
||
let handler_keys: Vec<String> = [
|
||
state
|
||
.on_enter
|
||
.as_ref()
|
||
.map(|_| format!("{}::enter", state.name)),
|
||
state
|
||
.on_exit
|
||
.as_ref()
|
||
.map(|_| format!("{}::exit", state.name)),
|
||
state
|
||
.on_frame
|
||
.as_ref()
|
||
.map(|_| format!("{}::frame", state.name)),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.collect();
|
||
|
||
for key in handler_keys {
|
||
let mut visited = HashSet::new();
|
||
let depth = compute_depth(&key, &self.call_graph, &mut visited, &mut cache);
|
||
self.max_depths.insert(key.clone(), depth);
|
||
|
||
if depth > self.stack_depth_limit {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0401,
|
||
format!(
|
||
"call depth {depth} in handler '{key}' exceeds stack limit {}",
|
||
self.stack_depth_limit
|
||
),
|
||
program.span,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn check_block(&mut self, block: &Block, state_names: &[&str]) {
|
||
let mut terminated_by: Option<Span> = None;
|
||
let mut warned_dead_code = false;
|
||
for stmt in &block.statements {
|
||
if let Some(term_span) = terminated_by {
|
||
if !warned_dead_code {
|
||
self.diagnostics.push(
|
||
Diagnostic::warning(
|
||
ErrorCode::W0104,
|
||
"unreachable code after return / break / transition",
|
||
stmt.span(),
|
||
)
|
||
.with_label(term_span, "execution stops here"),
|
||
);
|
||
warned_dead_code = true;
|
||
}
|
||
}
|
||
self.check_statement(stmt, state_names);
|
||
if stmt_is_terminator(stmt) && terminated_by.is_none() {
|
||
terminated_by = Some(stmt.span());
|
||
}
|
||
}
|
||
}
|
||
|
||
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.walk_expr_reads(init);
|
||
self.check_expr_type(init, &var.var_type);
|
||
}
|
||
}
|
||
Statement::Assign(lvalue, _, expr, span) => {
|
||
// Check if trying to assign to a constant
|
||
match lvalue {
|
||
LValue::Var(name) => {
|
||
if let Some(sym) = self.symbols.get(name) {
|
||
if sym.is_const {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
format!("cannot assign to constant '{name}'"),
|
||
*span,
|
||
));
|
||
}
|
||
} else {
|
||
// Assigning to an undeclared name is an
|
||
// error — the lowering would otherwise
|
||
// silently synthesize a VarId for it.
|
||
self.emit_undefined_var(name, *span);
|
||
}
|
||
}
|
||
LValue::ArrayIndex(name, idx) => {
|
||
if let Some(sym) = self.symbols.get(name) {
|
||
if sym.is_const {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
format!("cannot assign to constant '{name}'"),
|
||
*span,
|
||
));
|
||
}
|
||
} else {
|
||
self.emit_undefined_var(name, *span);
|
||
}
|
||
// Indexing an array counts as a read of the array,
|
||
// and the index expression itself may contain reads.
|
||
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);
|
||
if let Some(lt) = ltype {
|
||
self.check_expr_type(expr, <);
|
||
}
|
||
}
|
||
Statement::If(cond, then_block, else_ifs, else_block, _) => {
|
||
self.walk_expr_reads(cond);
|
||
self.check_expr_type(cond, &NesType::Bool);
|
||
self.check_block(then_block, state_names);
|
||
for (cond, block) in else_ifs {
|
||
self.walk_expr_reads(cond);
|
||
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.walk_expr_reads(cond);
|
||
self.check_expr_type(cond, &NesType::Bool);
|
||
let was_in_loop = self.in_loop;
|
||
self.in_loop = true;
|
||
self.check_block(body, state_names);
|
||
self.in_loop = was_in_loop;
|
||
}
|
||
Statement::For {
|
||
var,
|
||
start,
|
||
end,
|
||
body,
|
||
span,
|
||
} => {
|
||
// Evaluate start/end (both u8) for reads and type
|
||
// checking, then register the loop variable as a u8
|
||
// for the duration of the body.
|
||
self.walk_expr_reads(start);
|
||
self.walk_expr_reads(end);
|
||
self.check_expr_type(start, &NesType::U8);
|
||
self.check_expr_type(end, &NesType::U8);
|
||
let was_shadowed = self.symbols.remove(var);
|
||
self.symbols.insert(
|
||
var.clone(),
|
||
Symbol {
|
||
name: var.clone(),
|
||
sym_type: NesType::U8,
|
||
is_const: false,
|
||
span: *span,
|
||
},
|
||
);
|
||
// Synthesize a VarAllocation for the loop variable
|
||
// so IR lowering / codegen can treat it like any
|
||
// other u8 local.
|
||
let loop_var_addr = self.allocate_ram(1, *span).unwrap_or(0x10);
|
||
self.var_allocations.push(VarAllocation {
|
||
name: var.clone(),
|
||
address: loop_var_addr,
|
||
size: 1,
|
||
});
|
||
// Loop variable is always "used" in the header.
|
||
self.mark_var_used(var);
|
||
let was_in_loop = self.in_loop;
|
||
self.in_loop = true;
|
||
self.check_block(body, state_names);
|
||
self.in_loop = was_in_loop;
|
||
self.symbols.remove(var);
|
||
if let Some(old) = was_shadowed {
|
||
self.symbols.insert(var.clone(), old);
|
||
}
|
||
}
|
||
Statement::Loop(body, span) => {
|
||
let was_in_loop = self.in_loop;
|
||
self.in_loop = true;
|
||
self.check_block(body, state_names);
|
||
self.in_loop = was_in_loop;
|
||
// W0102: loop body must contain a break, return,
|
||
// transition, or wait_frame — otherwise the NES spins
|
||
// forever inside the loop and vblank never gets handled.
|
||
if !block_can_exit_or_yield(body) {
|
||
self.diagnostics.push(Diagnostic::warning(
|
||
ErrorCode::W0102,
|
||
"infinite loop with no break, return, transition, or wait_frame",
|
||
*span,
|
||
).with_help("add `wait_frame`, `break`, `return`, or `transition` somewhere in the body"));
|
||
}
|
||
}
|
||
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.walk_expr_reads(&draw.x);
|
||
self.walk_expr_reads(&draw.y);
|
||
self.check_expr_type(&draw.x, &NesType::U8);
|
||
self.check_expr_type(&draw.y, &NesType::U8);
|
||
if let Some(frame) = &draw.frame {
|
||
self.walk_expr_reads(frame);
|
||
self.check_expr_type(frame, &NesType::U8);
|
||
}
|
||
}
|
||
Statement::Return(Some(expr), span) => {
|
||
self.walk_expr_reads(expr);
|
||
if let Some(ret_ty) = self.current_return_type.clone() {
|
||
// Function with declared return type — check the value.
|
||
self.check_expr_type(expr, &ret_ty);
|
||
} else if self.in_function_body {
|
||
// Function with no declared return type ("void"),
|
||
// but the return statement has a value.
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
"return value in function with no declared return type",
|
||
*span,
|
||
));
|
||
}
|
||
// State handlers (`in_function_body == false`) accept
|
||
// `return value` silently — the value is simply discarded.
|
||
}
|
||
Statement::Call(name, args, span) => {
|
||
if is_intrinsic(name) {
|
||
self.check_intrinsic_args(name, args, *span);
|
||
} else if self.symbols.contains_key(name) {
|
||
self.check_call_signature(name, args, *span);
|
||
} else {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0503,
|
||
format!("undefined function '{name}'"),
|
||
*span,
|
||
));
|
||
}
|
||
for arg in args {
|
||
self.walk_expr_reads(arg);
|
||
}
|
||
}
|
||
Statement::Scroll(x, y, _) => {
|
||
self.walk_expr_reads(x);
|
||
self.walk_expr_reads(y);
|
||
self.check_expr_type(x, &NesType::U8);
|
||
self.check_expr_type(y, &NesType::U8);
|
||
}
|
||
Statement::Break(span) => {
|
||
if !self.in_loop {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
"break outside of loop",
|
||
*span,
|
||
));
|
||
}
|
||
}
|
||
Statement::Continue(span) => {
|
||
if !self.in_loop {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
"continue outside of loop",
|
||
*span,
|
||
));
|
||
}
|
||
}
|
||
Statement::Return(None, span) => {
|
||
// Bare `return` in a function with a declared return
|
||
// type is an error — the caller expects a value.
|
||
if self.in_function_body && self.current_return_type.is_some() {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
"missing return value in function with declared return type",
|
||
*span,
|
||
));
|
||
}
|
||
}
|
||
Statement::WaitFrame(_) => {}
|
||
Statement::SetPalette(name, span) => {
|
||
if !self.palette_names.contains(name) {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0502,
|
||
format!("unknown palette '{name}'"),
|
||
*span,
|
||
));
|
||
}
|
||
}
|
||
Statement::LoadBackground(name, span) => {
|
||
if !self.background_names.contains(name) {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0502,
|
||
format!("unknown background '{name}'"),
|
||
*span,
|
||
));
|
||
}
|
||
}
|
||
Statement::DebugLog(args, _) => {
|
||
for arg in args {
|
||
self.walk_expr_reads(arg);
|
||
}
|
||
}
|
||
Statement::DebugAssert(cond, _) => {
|
||
self.walk_expr_reads(cond);
|
||
self.check_expr_type(cond, &NesType::Bool);
|
||
}
|
||
Statement::InlineAsm(_, _) | Statement::RawAsm(_, _) => {
|
||
// Inline assembly is treated as an opaque block. The
|
||
// codegen parses and validates the body; analysis has
|
||
// nothing to check.
|
||
}
|
||
Statement::Play(name, span) => {
|
||
// `play Name` is valid if the name refers to a
|
||
// declared sfx block or to a builtin effect. Anything
|
||
// else is an E0505 — the runtime driver needs a
|
||
// known blob to point at, and silently accepting
|
||
// bad names would hide typos.
|
||
if !self.sfx_names.contains(name) && !crate::assets::is_builtin_sfx(name) {
|
||
self.diagnostics.push(
|
||
Diagnostic::error(ErrorCode::E0505, format!("unknown sfx '{name}'"), *span)
|
||
.with_help(
|
||
"declare one with `sfx Name { pitch: [..], volume: [..] }`, \
|
||
or use a builtin: coin, jump, hit, click, cancel, shoot, step",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
Statement::StartMusic(name, span) => {
|
||
if !self.music_names.contains(name) && !crate::assets::is_builtin_music(name) {
|
||
self.diagnostics.push(
|
||
Diagnostic::error(
|
||
ErrorCode::E0505,
|
||
format!("unknown music track '{name}'"),
|
||
*span,
|
||
)
|
||
.with_help(
|
||
"declare one with `music Name { notes: [..] }`, \
|
||
or use a builtin: theme, battle, victory, gameover",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
Statement::StopMusic(_) => {
|
||
// No arguments, nothing to validate.
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
LValue::Field(name, field) => {
|
||
let full_name = format!("{name}.{field}");
|
||
self.symbols.get(&full_name).map(|s| s.sym_type.clone())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Check that a call site matches the function's declared signature:
|
||
/// argument count matches the parameter count, and each argument's
|
||
/// inferred type is compatible with the declared parameter type.
|
||
fn check_call_signature(&mut self, name: &str, args: &[Expr], span: Span) {
|
||
let Some(params) = self.function_signatures.get(name).cloned() else {
|
||
return;
|
||
};
|
||
if params.len() != args.len() {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
format!(
|
||
"wrong number of arguments to '{name}': expected {}, got {}",
|
||
params.len(),
|
||
args.len()
|
||
),
|
||
span,
|
||
));
|
||
return;
|
||
}
|
||
for (param_ty, arg) in params.iter().zip(args.iter()) {
|
||
self.check_expr_type(arg, param_ty);
|
||
}
|
||
}
|
||
|
||
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,
|
||
})
|
||
}
|
||
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()),
|
||
Expr::StructLiteral(name, _, _) => Some(NesType::Struct(name.clone())),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Collect every state name mentioned in a transition statement inside the
|
||
/// given state's handlers and append them to `queue`. Used by the W0104
|
||
/// unreachable-state check.
|
||
fn collect_transitions_from_state(state: &StateDecl, queue: &mut Vec<String>) {
|
||
if let Some(block) = &state.on_enter {
|
||
collect_transitions_block(block, queue);
|
||
}
|
||
if let Some(block) = &state.on_exit {
|
||
collect_transitions_block(block, queue);
|
||
}
|
||
if let Some(block) = &state.on_frame {
|
||
collect_transitions_block(block, queue);
|
||
}
|
||
for (_, block) in &state.on_scanline {
|
||
collect_transitions_block(block, queue);
|
||
}
|
||
}
|
||
|
||
fn collect_transitions_block(block: &Block, queue: &mut Vec<String>) {
|
||
for stmt in &block.statements {
|
||
collect_transitions_stmt(stmt, queue);
|
||
}
|
||
}
|
||
|
||
fn collect_transitions_stmt(stmt: &Statement, queue: &mut Vec<String>) {
|
||
match stmt {
|
||
Statement::Transition(name, _) => queue.push(name.clone()),
|
||
Statement::If(_, then_b, elifs, else_b, _) => {
|
||
collect_transitions_block(then_b, queue);
|
||
for (_, b) in elifs {
|
||
collect_transitions_block(b, queue);
|
||
}
|
||
if let Some(b) = else_b {
|
||
collect_transitions_block(b, queue);
|
||
}
|
||
}
|
||
Statement::While(_, body, _) | Statement::Loop(body, _) => {
|
||
collect_transitions_block(body, queue);
|
||
}
|
||
Statement::For { body, .. } => {
|
||
collect_transitions_block(body, queue);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Collect all function/call names from a block.
|
||
fn collect_calls(block: &Block) -> Vec<String> {
|
||
let mut calls = Vec::new();
|
||
for stmt in &block.statements {
|
||
collect_calls_stmt(stmt, &mut calls);
|
||
}
|
||
calls
|
||
}
|
||
|
||
fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec<String>) {
|
||
match stmt {
|
||
Statement::Call(name, args, _) => {
|
||
calls.push(name.clone());
|
||
for arg in args {
|
||
collect_calls_expr(arg, calls);
|
||
}
|
||
}
|
||
Statement::If(cond, then_b, elifs, else_b, _) => {
|
||
collect_calls_expr(cond, calls);
|
||
collect_calls_block(then_b, calls);
|
||
for (c, b) in elifs {
|
||
collect_calls_expr(c, calls);
|
||
collect_calls_block(b, calls);
|
||
}
|
||
if let Some(b) = else_b {
|
||
collect_calls_block(b, calls);
|
||
}
|
||
}
|
||
Statement::While(cond, body, _) => {
|
||
collect_calls_expr(cond, calls);
|
||
collect_calls_block(body, calls);
|
||
}
|
||
Statement::Loop(body, _) => {
|
||
collect_calls_block(body, calls);
|
||
}
|
||
Statement::For {
|
||
start, end, body, ..
|
||
} => {
|
||
collect_calls_expr(start, calls);
|
||
collect_calls_expr(end, calls);
|
||
collect_calls_block(body, calls);
|
||
}
|
||
Statement::Assign(_, _, expr, _) => {
|
||
collect_calls_expr(expr, calls);
|
||
}
|
||
Statement::VarDecl(var) => {
|
||
if let Some(init) = &var.init {
|
||
collect_calls_expr(init, calls);
|
||
}
|
||
}
|
||
Statement::Return(Some(expr), _) => {
|
||
collect_calls_expr(expr, calls);
|
||
}
|
||
Statement::Draw(draw) => {
|
||
collect_calls_expr(&draw.x, calls);
|
||
collect_calls_expr(&draw.y, calls);
|
||
if let Some(f) = &draw.frame {
|
||
collect_calls_expr(f, calls);
|
||
}
|
||
}
|
||
Statement::Scroll(x, y, _) => {
|
||
collect_calls_expr(x, calls);
|
||
collect_calls_expr(y, calls);
|
||
}
|
||
Statement::DebugLog(args, _) => {
|
||
for arg in args {
|
||
collect_calls_expr(arg, calls);
|
||
}
|
||
}
|
||
Statement::DebugAssert(cond, _) => {
|
||
collect_calls_expr(cond, calls);
|
||
}
|
||
Statement::Return(None, _)
|
||
| Statement::Transition(_, _)
|
||
| Statement::WaitFrame(_)
|
||
| Statement::Break(_)
|
||
| Statement::Continue(_)
|
||
| Statement::InlineAsm(_, _)
|
||
| Statement::RawAsm(_, _)
|
||
| Statement::Play(_, _)
|
||
| Statement::StartMusic(_, _)
|
||
| Statement::StopMusic(_)
|
||
| Statement::SetPalette(_, _)
|
||
| Statement::LoadBackground(_, _) => {}
|
||
}
|
||
}
|
||
|
||
fn collect_calls_block(block: &Block, calls: &mut Vec<String>) {
|
||
for stmt in &block.statements {
|
||
collect_calls_stmt(stmt, calls);
|
||
}
|
||
}
|
||
|
||
/// Return true if the given block contains any statement that can
|
||
/// either exit the enclosing loop (`break`, `return`, `transition`)
|
||
/// or yield control back to the frame loop (`wait_frame`).
|
||
///
|
||
/// This is used by the W0102 check to decide whether an otherwise-
|
||
/// unbounded `loop { }` is actually an infinite spin. We recurse into
|
||
/// nested control-flow blocks so that a `break` inside a conditional
|
||
/// body still counts as "can exit".
|
||
/// True if the expression is a small integer literal — used to avoid
|
||
/// emitting W0101 for multiply/divide where at least one operand can be
|
||
/// handled by strength reduction (e.g. `x * 2`, `x / 4`).
|
||
fn is_small_constant(expr: &Expr) -> bool {
|
||
matches!(expr, Expr::IntLiteral(_, _))
|
||
}
|
||
|
||
/// True if `name` is a built-in intrinsic function recognized by the
|
||
/// compiler. Intrinsics don't need a declaration and may have
|
||
/// special codegen (e.g. \`poke\` / \`peek\` write to raw addresses).
|
||
fn is_intrinsic(name: &str) -> bool {
|
||
matches!(name, "poke" | "peek")
|
||
}
|
||
|
||
impl Analyzer {
|
||
/// Validate the arguments to a built-in intrinsic. Emits
|
||
/// diagnostics for mismatched arity or non-constant addresses.
|
||
fn check_intrinsic_args(&mut self, name: &str, args: &[Expr], span: Span) {
|
||
match name {
|
||
"poke" => {
|
||
if args.len() != 2 {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
format!(
|
||
"`poke` takes exactly 2 arguments (addr, value), got {}",
|
||
args.len()
|
||
),
|
||
span,
|
||
));
|
||
}
|
||
}
|
||
"peek" => {
|
||
if args.len() != 1 {
|
||
self.diagnostics.push(Diagnostic::error(
|
||
ErrorCode::E0203,
|
||
format!("`peek` takes exactly 1 argument (addr), got {}", args.len()),
|
||
span,
|
||
));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// True if this statement unconditionally ends block execution —
|
||
/// subsequent statements in the same block cannot be reached.
|
||
fn stmt_is_terminator(stmt: &Statement) -> bool {
|
||
matches!(
|
||
stmt,
|
||
Statement::Return(_, _)
|
||
| Statement::Break(_)
|
||
| Statement::Continue(_)
|
||
| Statement::Transition(_, _)
|
||
)
|
||
}
|
||
|
||
fn block_can_exit_or_yield(block: &Block) -> bool {
|
||
block.statements.iter().any(stmt_can_exit_or_yield)
|
||
}
|
||
|
||
fn stmt_can_exit_or_yield(stmt: &Statement) -> bool {
|
||
match stmt {
|
||
Statement::Break(_)
|
||
| Statement::Return(_, _)
|
||
| Statement::Transition(_, _)
|
||
| Statement::WaitFrame(_) => true,
|
||
Statement::If(_, then_b, elifs, else_b, _) => {
|
||
block_can_exit_or_yield(then_b)
|
||
|| elifs.iter().any(|(_, b)| block_can_exit_or_yield(b))
|
||
|| else_b.as_ref().is_some_and(block_can_exit_or_yield)
|
||
}
|
||
Statement::While(_, body, _) | Statement::Loop(body, _) => {
|
||
// A nested loop with a wait_frame inside still yields
|
||
// control, so check its body recursively.
|
||
block_can_exit_or_yield(body)
|
||
}
|
||
Statement::For { body, .. } => block_can_exit_or_yield(body),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn collect_calls_expr(expr: &Expr, calls: &mut Vec<String>) {
|
||
match expr {
|
||
Expr::Call(name, args, _) => {
|
||
calls.push(name.clone());
|
||
for arg in args {
|
||
collect_calls_expr(arg, calls);
|
||
}
|
||
}
|
||
Expr::BinaryOp(lhs, _, rhs, _) => {
|
||
collect_calls_expr(lhs, calls);
|
||
collect_calls_expr(rhs, calls);
|
||
}
|
||
Expr::UnaryOp(_, inner, _) => {
|
||
collect_calls_expr(inner, calls);
|
||
}
|
||
Expr::ArrayIndex(_, idx, _) => {
|
||
collect_calls_expr(idx, calls);
|
||
}
|
||
Expr::ArrayLiteral(elems, _) => {
|
||
for e in elems {
|
||
collect_calls_expr(e, calls);
|
||
}
|
||
}
|
||
Expr::StructLiteral(_, fields, _) => {
|
||
for (_, e) in fields {
|
||
collect_calls_expr(e, calls);
|
||
}
|
||
}
|
||
Expr::Cast(inner, _, _) => {
|
||
collect_calls_expr(inner, calls);
|
||
}
|
||
Expr::IntLiteral(_, _)
|
||
| Expr::BoolLiteral(_, _)
|
||
| Expr::Ident(_, _)
|
||
| Expr::FieldAccess(_, _, _)
|
||
| Expr::ButtonRead(_, _, _) => {}
|
||
}
|
||
}
|
||
|
||
/// Detect cycles in the call graph using DFS. Returns the names of all
|
||
/// functions that participate in a cycle (direct or mutual recursion).
|
||
fn detect_recursion(graph: &HashMap<String, Vec<String>>) -> Vec<String> {
|
||
let mut recursive = Vec::new();
|
||
let mut visited = HashSet::new();
|
||
let mut on_stack = HashSet::new();
|
||
|
||
for node in graph.keys() {
|
||
if !visited.contains(node) {
|
||
detect_recursion_dfs(node, graph, &mut visited, &mut on_stack, &mut recursive);
|
||
}
|
||
}
|
||
|
||
recursive.sort();
|
||
recursive.dedup();
|
||
recursive
|
||
}
|
||
|
||
fn detect_recursion_dfs(
|
||
node: &str,
|
||
graph: &HashMap<String, Vec<String>>,
|
||
visited: &mut HashSet<String>,
|
||
on_stack: &mut HashSet<String>,
|
||
recursive: &mut Vec<String>,
|
||
) {
|
||
visited.insert(node.to_string());
|
||
on_stack.insert(node.to_string());
|
||
|
||
if let Some(callees) = graph.get(node) {
|
||
for callee in callees {
|
||
if on_stack.contains(callee) {
|
||
// Found a cycle — mark the callee (the one we recursed back to)
|
||
recursive.push(callee.clone());
|
||
} else if !visited.contains(callee) {
|
||
detect_recursion_dfs(callee, graph, visited, on_stack, recursive);
|
||
}
|
||
}
|
||
}
|
||
|
||
on_stack.remove(node);
|
||
}
|
||
|
||
/// Compute the maximum call depth starting from a given node in the call graph.
|
||
/// Returns `None` if a cycle is encountered (handled separately by recursion detection).
|
||
fn compute_depth(
|
||
node: &str,
|
||
graph: &HashMap<String, Vec<String>>,
|
||
visited: &mut HashSet<String>,
|
||
cache: &mut HashMap<String, u32>,
|
||
) -> u32 {
|
||
if let Some(&depth) = cache.get(node) {
|
||
return depth;
|
||
}
|
||
if visited.contains(node) {
|
||
// Cycle — return 0 to avoid infinite recursion; the cycle itself
|
||
// is flagged by detect_recursion.
|
||
return 0;
|
||
}
|
||
visited.insert(node.to_string());
|
||
let mut max_child: u32 = 0;
|
||
if let Some(callees) = graph.get(node) {
|
||
for callee in callees {
|
||
let child = compute_depth(callee, graph, visited, cache);
|
||
max_child = max_child.max(child);
|
||
}
|
||
}
|
||
visited.remove(node);
|
||
let depth = if graph.get(node).is_none_or(Vec::is_empty) {
|
||
0
|
||
} else {
|
||
1 + max_child
|
||
};
|
||
cache.insert(node.to_string(), depth);
|
||
depth
|
||
}
|
||
|
||
/// 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<String, u16>) -> u16 {
|
||
match t {
|
||
NesType::U8 | NesType::I8 | NesType::Bool => 1,
|
||
NesType::U16 => 2,
|
||
NesType::Array(elem, count) => type_size_with(elem, struct_sizes) * count,
|
||
NesType::Struct(name) => struct_sizes.get(name).copied().unwrap_or(0),
|
||
}
|
||
}
|
||
|
||
fn is_integer_type(t: &NesType) -> bool {
|
||
matches!(t, NesType::U8 | NesType::I8 | NesType::U16)
|
||
}
|