mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 09:18:01 +00:00
M2: Add call graph analysis, recursion detection, and optimizer
Analyzer extensions: - Call graph construction from function bodies and state handlers - DFS-based recursion detection (direct and mutual) with E0402 errors - Max call depth computation per entry point with E0401 enforcement - Function declarations registered as symbols (E0503 for undefined calls) - Collects calls from all statement/expression types recursively Optimizer (new module): - Constant folding: evaluate known-constant arithmetic at compile time - Dead code elimination: remove ops with unused destination temps - Both operate per-basic-block in a single pass 171 tests total (22 new: 6 analyzer + 11 IR lowering + 5 optimizer) https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
parent
664ccc05db
commit
192d9c5c3d
9 changed files with 914 additions and 23 deletions
|
|
@ -1,7 +1,7 @@
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::errors::{Diagnostic, ErrorCode};
|
||||
use crate::lexer::Span;
|
||||
|
|
@ -29,8 +29,13 @@ 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;
|
||||
|
||||
/// Analyze a parsed program for semantic errors.
|
||||
pub fn analyze(program: &Program) -> AnalysisResult {
|
||||
let mut analyzer = Analyzer {
|
||||
|
|
@ -39,6 +44,9 @@ pub fn analyze(program: &Program) -> AnalysisResult {
|
|||
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
|
||||
call_graph: HashMap::new(),
|
||||
max_depths: HashMap::new(),
|
||||
stack_depth_limit: DEFAULT_STACK_DEPTH,
|
||||
};
|
||||
analyzer.analyze_program(program);
|
||||
|
||||
|
|
@ -46,6 +54,8 @@ pub fn 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +65,9 @@ struct Analyzer {
|
|||
diagnostics: Vec<Diagnostic>,
|
||||
next_ram_addr: u16,
|
||||
next_zp_addr: u8,
|
||||
call_graph: HashMap<String, Vec<String>>,
|
||||
max_depths: HashMap<String, u32>,
|
||||
stack_depth_limit: u32,
|
||||
}
|
||||
|
||||
impl Analyzer {
|
||||
|
|
@ -69,6 +82,11 @@ impl Analyzer {
|
|||
self.register_var(var);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -100,6 +118,27 @@ impl Analyzer {
|
|||
self.check_block(block, &state_names);
|
||||
}
|
||||
}
|
||||
|
||||
// Type-check function bodies
|
||||
for fun in &program.functions {
|
||||
self.check_block(&fun.body, &state_names);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
fn register_const(&mut self, c: &ConstDecl) {
|
||||
|
|
@ -152,6 +191,27 @@ impl Analyzer {
|
|||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -165,6 +225,75 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
|
||||
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]) {
|
||||
for stmt in &block.statements {
|
||||
self.check_statement(stmt, state_names);
|
||||
|
|
@ -225,9 +354,8 @@ impl Analyzer {
|
|||
}
|
||||
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,
|
||||
ErrorCode::E0503,
|
||||
format!("undefined function '{name}'"),
|
||||
*span,
|
||||
));
|
||||
|
|
@ -334,6 +462,179 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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::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::Return(None, _)
|
||||
| Statement::Transition(_, _)
|
||||
| Statement::WaitFrame(_)
|
||||
| Statement::Break(_)
|
||||
| Statement::Continue(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_calls_block(block: &Block, calls: &mut Vec<String>) {
|
||||
for stmt in &block.statements {
|
||||
collect_calls_stmt(stmt, calls);
|
||||
}
|
||||
}
|
||||
|
||||
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::IntLiteral(_, _)
|
||||
| Expr::BoolLiteral(_, _)
|
||||
| Expr::Ident(_, _)
|
||||
| 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
|
||||
}
|
||||
|
||||
fn type_size(t: &NesType) -> u16 {
|
||||
match t {
|
||||
NesType::U8 | NesType::I8 | NesType::Bool => 1,
|
||||
|
|
|
|||
|
|
@ -126,3 +126,100 @@ fn analyze_const_symbol() {
|
|||
let sym = result.symbols.get("SPEED").unwrap();
|
||||
assert!(sym.is_const);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_function_registered() {
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
fun add(a: u8, b: u8) -> u8 { return a }
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(result.symbols.contains_key("add"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_recursion_detected() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
fun a() { a() }
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(errors.contains(&ErrorCode::E0402));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_mutual_recursion() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
fun a() { b() }
|
||||
fun b() { a() }
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(errors.contains(&ErrorCode::E0402));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_call_depth_ok() {
|
||||
// 3 levels of nesting — well within the default limit of 8
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
fun c() { wait_frame }
|
||||
fun b() { c() }
|
||||
fun a() { b() }
|
||||
on frame { a() }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
// The frame handler's depth should be <= 8
|
||||
for &depth in result.max_depths.values() {
|
||||
assert!(depth <= 8, "depth {depth} should be within limit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_call_depth_exceeded() {
|
||||
// Build a call chain deeper than 8: f1 -> f2 -> ... -> f10
|
||||
let result = analyze_errors(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
fun f10() { wait_frame }
|
||||
fun f9() { f10() }
|
||||
fun f8() { f9() }
|
||||
fun f7() { f8() }
|
||||
fun f6() { f7() }
|
||||
fun f5() { f6() }
|
||||
fun f4() { f5() }
|
||||
fun f3() { f4() }
|
||||
fun f2() { f3() }
|
||||
fun f1() { f2() }
|
||||
on frame { f1() }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
result.contains(&ErrorCode::E0401),
|
||||
"expected E0401 for exceeded call depth, got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_undefined_function() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
on frame { no_such_fn() }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(errors.contains(&ErrorCode::E0503));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue